loading
Generated 2025-12-21T20:27:12+09:00

All Files ( 77.9% covered at 16.74 hits/line )

79 files in total.
8813 relevant lines, 6865 lines covered and 1948 lines missed. ( 77.9% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 80.99 % 274 142 115 27 3.52
lib/cli/commands/generate.rb 83.20 % 419 244 203 41 3.77
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 37.38 % 225 107 40 67 1.68
lib/cli/commands/init.rb 68.29 % 206 82 56 26 3.78
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.43
lib/compose/components/blurview_component.rb 76.47 % 66 34 26 8 1.94
lib/compose/components/button_component.rb 97.06 % 194 102 99 3 10.67
lib/compose/components/checkbox_component.rb 37.75 % 268 151 57 94 1.94
lib/compose/components/circleimage_component.rb 100.00 % 101 53 53 0 15.70
lib/compose/components/collection_component.rb 99.53 % 355 211 210 1 17.82
lib/compose/components/constraintlayout_component.rb 91.03 % 288 145 132 13 10.46
lib/compose/components/container_component.rb 84.62 % 205 91 77 14 10.36
lib/compose/components/gradientview_component.rb 72.73 % 89 44 32 12 2.57
lib/compose/components/image_component.rb 87.23 % 92 47 41 6 7.02
lib/compose/components/indicator_component.rb 64.29 % 105 56 36 20 1.66
lib/compose/components/networkimage_component.rb 74.14 % 112 58 43 15 3.29
lib/compose/components/progress_component.rb 97.87 % 84 47 46 1 6.66
lib/compose/components/radio_component.rb 92.49 % 364 213 197 16 8.10
lib/compose/components/scrollview_component.rb 100.00 % 88 43 43 0 10.40
lib/compose/components/segment_component.rb 78.79 % 288 165 130 35 18.67
lib/compose/components/selectbox_component.rb 89.08 % 224 119 106 13 14.61
lib/compose/components/slider_component.rb 98.59 % 127 71 70 1 14.54
lib/compose/components/switch_component.rb 38.97 % 242 136 53 83 2.21
lib/compose/components/table_component.rb 100.00 % 175 108 108 0 24.69
lib/compose/components/tabview_component.rb 91.23 % 103 57 52 5 24.95
lib/compose/components/text_component.rb 68.67 % 646 332 228 104 11.45
lib/compose/components/textfield_component.rb 78.50 % 395 200 157 43 16.24
lib/compose/components/textview_component.rb 80.11 % 362 181 145 36 23.93
lib/compose/components/toggle_component.rb 96.23 % 94 53 51 2 8.70
lib/compose/components/web_component.rb 100.00 % 100 51 51 0 26.98
lib/compose/components/webview_component.rb 100.00 % 73 41 41 0 10.10
lib/compose/compose_builder.rb 56.24 % 878 489 275 214 2.61
lib/compose/data_model_updater.rb 86.38 % 472 235 203 32 8.02
lib/compose/generators/cell_generator.rb 96.81 % 338 94 91 3 10.61
lib/compose/generators/converter_generator.rb 82.43 % 462 148 122 26 5.89
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.46
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.69
lib/compose/generators/view_generator.rb 74.81 % 441 135 101 34 10.36
lib/compose/helpers/import_manager.rb 100.00 % 147 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 71.76 % 703 393 282 111 46.99
lib/compose/helpers/resource_resolver.rb 84.68 % 245 111 94 17 25.58
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.81
lib/compose/setup/compose_setup.rb 45.30 % 375 117 53 64 1.23
lib/compose/style_loader.rb 100.00 % 77 39 39 0 8.03
lib/core/attribute_validator.rb 76.53 % 625 277 212 65 207.91
lib/core/binding_validator.rb 84.43 % 364 122 103 19 21.07
lib/core/config_manager.rb 98.86 % 195 88 87 1 30.52
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 11.75
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.88
lib/core/resources/color_manager.rb 87.32 % 692 355 310 45 9.35
lib/core/resources/string_manager.rb 61.60 % 479 237 146 91 4.72
lib/core/resources_manager.rb 100.00 % 82 45 45 0 7.33
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29
lib/core/type_converter.rb 73.47 % 278 98 72 26 9.31
lib/hotloader/ip_monitor.rb 94.57 % 180 92 87 5 6.10
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 81.44 % 214 97 79 18 13.68
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.61 % 233 134 104 30 2.71
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Core ( 81.39% covered at 51.6 hits/line )

11 files in total.
1349 relevant lines, 1098 lines covered and 251 lines missed. ( 81.39% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/core/attribute_validator.rb 76.53 % 625 277 212 65 207.91
lib/core/binding_validator.rb 84.43 % 364 122 103 19 21.07
lib/core/config_manager.rb 98.86 % 195 88 87 1 30.52
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 11.75
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.88
lib/core/resources/color_manager.rb 87.32 % 692 355 310 45 9.35
lib/core/resources/string_manager.rb 61.60 % 479 237 146 91 4.72
lib/core/resources_manager.rb 100.00 % 82 45 45 0 7.33
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29
lib/core/type_converter.rb 73.47 % 278 98 72 26 9.31

CLI ( 71.54% covered at 3.03 hits/line )

8 files in total.
738 relevant lines, 528 lines covered and 210 lines missed. ( 71.54% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 80.99 % 274 142 115 27 3.52
lib/cli/commands/generate.rb 83.20 % 419 244 203 41 3.77
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 37.38 % 225 107 40 67 1.68
lib/cli/commands/init.rb 68.29 % 206 82 56 26 3.78
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00

Compose ( 77.49% covered at 13.19 hits/line )

40 files in total.
4976 relevant lines, 3856 lines covered and 1120 lines missed. ( 77.49% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.43
lib/compose/components/blurview_component.rb 76.47 % 66 34 26 8 1.94
lib/compose/components/button_component.rb 97.06 % 194 102 99 3 10.67
lib/compose/components/checkbox_component.rb 37.75 % 268 151 57 94 1.94
lib/compose/components/circleimage_component.rb 100.00 % 101 53 53 0 15.70
lib/compose/components/collection_component.rb 99.53 % 355 211 210 1 17.82
lib/compose/components/constraintlayout_component.rb 91.03 % 288 145 132 13 10.46
lib/compose/components/container_component.rb 84.62 % 205 91 77 14 10.36
lib/compose/components/gradientview_component.rb 72.73 % 89 44 32 12 2.57
lib/compose/components/image_component.rb 87.23 % 92 47 41 6 7.02
lib/compose/components/indicator_component.rb 64.29 % 105 56 36 20 1.66
lib/compose/components/networkimage_component.rb 74.14 % 112 58 43 15 3.29
lib/compose/components/progress_component.rb 97.87 % 84 47 46 1 6.66
lib/compose/components/radio_component.rb 92.49 % 364 213 197 16 8.10
lib/compose/components/scrollview_component.rb 100.00 % 88 43 43 0 10.40
lib/compose/components/segment_component.rb 78.79 % 288 165 130 35 18.67
lib/compose/components/selectbox_component.rb 89.08 % 224 119 106 13 14.61
lib/compose/components/slider_component.rb 98.59 % 127 71 70 1 14.54
lib/compose/components/switch_component.rb 38.97 % 242 136 53 83 2.21
lib/compose/components/table_component.rb 100.00 % 175 108 108 0 24.69
lib/compose/components/tabview_component.rb 91.23 % 103 57 52 5 24.95
lib/compose/components/text_component.rb 68.67 % 646 332 228 104 11.45
lib/compose/components/textfield_component.rb 78.50 % 395 200 157 43 16.24
lib/compose/components/textview_component.rb 80.11 % 362 181 145 36 23.93
lib/compose/components/toggle_component.rb 96.23 % 94 53 51 2 8.70
lib/compose/components/web_component.rb 100.00 % 100 51 51 0 26.98
lib/compose/components/webview_component.rb 100.00 % 73 41 41 0 10.10
lib/compose/compose_builder.rb 56.24 % 878 489 275 214 2.61
lib/compose/data_model_updater.rb 86.38 % 472 235 203 32 8.02
lib/compose/generators/cell_generator.rb 96.81 % 338 94 91 3 10.61
lib/compose/generators/converter_generator.rb 82.43 % 462 148 122 26 5.89
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.46
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.69
lib/compose/generators/view_generator.rb 74.81 % 441 135 101 34 10.36
lib/compose/helpers/import_manager.rb 100.00 % 147 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 71.76 % 703 393 282 111 46.99
lib/compose/helpers/resource_resolver.rb 84.68 % 245 111 94 17 25.58
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.81
lib/compose/setup/compose_setup.rb 45.30 % 375 117 53 64 1.23
lib/compose/style_loader.rb 100.00 % 77 39 39 0 8.03

XML ( 78.17% covered at 5.72 hits/line )

19 files in total.
1658 relevant lines, 1296 lines covered and 362 lines missed. ( 78.17% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 81.44 % 214 97 79 18 13.68
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.61 % 233 134 104 30 2.71
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Ungrouped ( 94.57% covered at 6.1 hits/line )

1 files in total.
92 relevant lines, 87 lines covered and 5 lines missed. ( 94.57% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/hotloader/ip_monitor.rb 94.57 % 180 92 87 5 6.10

lib/cli/commands/build.rb

80.99% lines covered

142 relevant lines. 115 lines covered and 27 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 require_relative '../../core/logger'
  7. 1 require_relative '../../core/attribute_validator'
  8. 1 require_relative '../../core/binding_validator'
  9. 1 module KjuiTools
  10. 1 module CLI
  11. 1 module Commands
  12. 1 class Build
  13. 1 def run(args)
  14. 9 options = parse_options(args)
  15. # Detect mode
  16. 9 mode = options[:mode] || Core::ConfigManager.get('mode') || 'compose'
  17. # Store validation results
  18. 9 @validation_warnings = []
  19. 9 @validation_errors = 0
  20. 9 case mode
  21. when 'xml', 'all'
  22. 1 build_xml(options)
  23. end
  24. 9 if mode == 'compose' || mode == 'all'
  25. 8 build_compose(options)
  26. end
  27. # Print validation summary if there were warnings
  28. 9 print_validation_summary if options[:validate] != false && @validation_warnings.any?
  29. # Exit with error code if strict mode and there were validation errors
  30. 9 if options[:strict] && @validation_errors > 0
  31. Core::Logger.error "Build failed: #{@validation_errors} validation error(s)"
  32. exit 1
  33. end
  34. end
  35. 1 private
  36. 1 def parse_options(args)
  37. 9 options = {}
  38. 9 OptionParser.new do |opts|
  39. 9 opts.banner = "Usage: kjui build [options]"
  40. 9 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  41. 'Build mode (all, xml, compose)') do |mode|
  42. 2 options[:mode] = mode
  43. end
  44. 9 opts.on('--clean', 'Clean cache before building') do
  45. 1 options[:clean] = true
  46. end
  47. 9 opts.on('--no-validate', 'Skip JSON attribute validation') do
  48. 2 options[:validate] = false
  49. end
  50. 9 opts.on('--strict', 'Fail build on validation errors') do
  51. 1 options[:strict] = true
  52. end
  53. 9 opts.on('-h', '--help', 'Show this help message') do
  54. puts opts
  55. exit
  56. end
  57. end.parse!(args)
  58. # Validation is enabled by default
  59. 9 options[:validate] = true if options[:validate].nil?
  60. 9 options
  61. end
  62. 1 def print_validation_summary
  63. 2 Core::Logger.info "-" * 60
  64. 2 Core::Logger.warn "Validation Summary: #{@validation_warnings.length} warning(s) found"
  65. 2 @validation_warnings.each do |warning|
  66. 9 puts " \e[33m#{warning}\e[0m"
  67. end
  68. end
  69. # Validate a JSON component and all its children recursively
  70. 1 def validate_json(json_data, validator, file_name)
  71. 4 return [] unless json_data.is_a?(Hash)
  72. 4 warnings = validator.validate(json_data)
  73. # Validate children recursively
  74. 4 children = json_data['child'] || json_data['children'] || []
  75. 4 children = [children] unless children.is_a?(Array)
  76. 4 children.each do |child|
  77. 2 warnings.concat(validate_json(child, validator, file_name)) if child.is_a?(Hash)
  78. end
  79. # Validate sections (for Collection/Table)
  80. 4 if json_data['sections'].is_a?(Array)
  81. json_data['sections'].each do |section|
  82. if section.is_a?(Hash)
  83. ['header', 'footer', 'cell'].each do |key|
  84. warnings.concat(validate_json(section[key], validator, file_name)) if section[key].is_a?(Hash)
  85. end
  86. end
  87. end
  88. end
  89. 4 warnings
  90. end
  91. 1 def build_xml(options = {})
  92. Core::Logger.info "Building XML View files..."
  93. # Setup project paths
  94. Core::ProjectFinder.setup_paths
  95. require_relative '../../xml/xml_builder'
  96. builder = Xml::XmlBuilder.new
  97. # Pass validation options to builder
  98. builder.validation_enabled = options[:validate]
  99. builder.validation_callback = ->(file, warnings) {
  100. if warnings.any?
  101. @validation_warnings.concat(warnings.map { |w| "[#{file}] #{w}" })
  102. @validation_errors += warnings.length
  103. end
  104. } if options[:validate]
  105. builder.build(options)
  106. Core::Logger.success "XML build completed!"
  107. end
  108. 1 def build_compose(options = {})
  109. 8 Core::Logger.info "Building Compose files..."
  110. # Setup project paths
  111. 8 Core::ProjectFinder.setup_paths
  112. 8 require_relative '../../compose/compose_builder'
  113. 8 require_relative '../../compose/build_cache_manager'
  114. 8 config = Core::ConfigManager.load_config
  115. 8 source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  116. 8 source_directory = config['source_directory'] || 'src/main'
  117. 8 layouts_dir = File.join(source_path, source_directory, config['layouts_directory'] || 'assets/Layouts')
  118. # Initialize cache manager
  119. 8 cache_manager = Compose::BuildCacheManager.new(source_path)
  120. # Clean cache if --clean option is specified
  121. 8 if options[:clean]
  122. 1 Core::Logger.info "Cleaning build cache..."
  123. 1 cache_manager.clean_cache
  124. end
  125. 8 last_updated = cache_manager.load_last_updated
  126. 8 last_including_files = cache_manager.load_last_including_files
  127. 8 style_dependencies = cache_manager.load_style_dependencies
  128. # Process all JSON files in Layouts directory (excluding Resources folder)
  129. 8 json_files = Dir.glob(File.join(layouts_dir, '**/*.json')).reject do |file|
  130. 3 file.include?('/Resources/')
  131. end
  132. 8 if json_files.empty?
  133. 5 Core::Logger.warn "No JSON files found in #{layouts_dir}"
  134. 5 return
  135. end
  136. # Extract resources before processing layouts
  137. 3 require_relative '../../core/resources_manager'
  138. 3 resources_manager = Core::ResourcesManager.new(config, source_path)
  139. 3 resources_manager.extract_resources(json_files)
  140. 3 Core::Logger.info "-" * 60
  141. # Track new includes and style dependencies
  142. 3 new_including_files = {}
  143. 3 new_style_dependencies = {}
  144. # Filter files that need update
  145. 3 files_to_update = []
  146. 3 json_files.each do |json_file|
  147. 3 file_name = File.basename(json_file, '.json')
  148. # Check if file needs update
  149. 3 if cache_manager.needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  150. 3 files_to_update << json_file
  151. else
  152. # Keep existing includes and style dependencies for unchanged files
  153. new_including_files[file_name] = last_including_files[file_name] if last_including_files[file_name]
  154. new_style_dependencies[file_name] = style_dependencies[file_name] if style_dependencies[file_name]
  155. end
  156. end
  157. # Update data models first (always run to ensure data models are in sync)
  158. 3 require_relative '../../compose/data_model_updater'
  159. 3 data_updater = Compose::DataModelUpdater.new
  160. 3 data_updater.update_data_models(files_to_update)
  161. 3 if files_to_update.empty?
  162. Core::Logger.info "No files need updating (all cached)"
  163. return
  164. end
  165. 3 Core::Logger.info "Updating #{files_to_update.length} of #{json_files.length} files..."
  166. # Initialize validators if validation is enabled
  167. 3 validator = options[:validate] ? Core::AttributeValidator.new(:compose) : nil
  168. 3 binding_validator = options[:validate] ? Core::BindingValidator.new : nil
  169. 3 builder = Compose::ComposeBuilder.new
  170. 3 files_to_update.each do |json_file|
  171. 3 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  172. 3 file_name = File.basename(json_file, '.json')
  173. begin
  174. # Read and parse JSON
  175. 3 json_content = File.read(json_file)
  176. 3 json_data = JSON.parse(json_content)
  177. # Validate attributes if enabled
  178. 3 if validator
  179. 2 warnings = validate_json(json_data, validator, file_name)
  180. 2 if warnings.any?
  181. 11 @validation_warnings.concat(warnings.map { |w| "[#{relative_path}] #{w}" })
  182. 2 @validation_errors += warnings.length
  183. 2 Core::Logger.warn " #{warnings.length} attribute warning(s) in #{relative_path}"
  184. end
  185. end
  186. # Validate bindings for business logic
  187. 3 if binding_validator
  188. 2 binding_warnings = binding_validator.validate(json_data, relative_path)
  189. 2 if binding_warnings.any?
  190. @validation_warnings.concat(binding_warnings)
  191. Core::Logger.warn " #{binding_warnings.length} binding warning(s) in #{relative_path}"
  192. end
  193. end
  194. # Extract includes and styles for cache tracking
  195. 3 includes = cache_manager.extract_includes(json_data)
  196. 3 styles = cache_manager.extract_styles(json_data)
  197. 3 new_including_files[file_name] = includes if includes.any?
  198. 3 new_style_dependencies[file_name] = styles if styles.any?
  199. # Build Compose file
  200. 3 Core::Logger.info "Processing: #{relative_path}"
  201. 3 builder.build_file(json_file)
  202. rescue JSON::ParserError => e
  203. Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  204. rescue => e
  205. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  206. end
  207. end
  208. # Save cache for next build
  209. 3 cache_manager.save_cache(new_including_files, new_style_dependencies)
  210. 3 Core::Logger.success "Compose build completed!"
  211. end
  212. end
  213. end
  214. end
  215. end

lib/cli/commands/generate.rb

83.2% lines covered

244 relevant lines. 203 lines covered and 41 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require_relative '../../core/config_manager'
  4. 1 require_relative '../../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module CLI
  7. 1 module Commands
  8. 1 class Generate
  9. 1 SUBCOMMANDS = {
  10. 'view' => 'Generate a new view with JSON and binding',
  11. 'partial' => 'Generate a partial view',
  12. 'collection' => 'Generate a collection view',
  13. 'cell' => 'Generate a collection cell view',
  14. 'binding' => 'Generate binding file',
  15. 'converter' => 'Generate a custom component converter'
  16. }.freeze
  17. 1 def run(args)
  18. # Parse global options first
  19. 25 global_options = parse_global_options(args)
  20. 25 subcommand = args.shift
  21. # Load config to get default mode
  22. 25 config = Core::ConfigManager.load_config
  23. # Use mode from options if provided, otherwise from config, otherwise default to compose
  24. 25 mode = global_options[:mode] || config['mode'] || 'compose'
  25. # If no subcommand, generate all based on mode
  26. 25 if subcommand.nil?
  27. 6 if mode == 'xml'
  28. 1 generate_all_xml_layouts(config)
  29. else
  30. 5 generate_all_compose_views(config)
  31. end
  32. 6 return
  33. end
  34. 19 if subcommand == 'help' || subcommand == '--help' || subcommand == '-h'
  35. 2 show_help
  36. 2 return
  37. end
  38. 17 unless SUBCOMMANDS.key?(subcommand)
  39. # Check if it's a layout name (no subcommand, just generate that layout)
  40. 3 if mode == 'xml' && !subcommand.start_with?('-')
  41. 1 generate_specific_xml_layout(subcommand, args, config)
  42. 1 return
  43. 2 elsif !subcommand.start_with?('-')
  44. # For compose mode, treat it as a layout name and build it
  45. 1 puts "Building layout: #{subcommand}"
  46. 1 generate_specific_compose_layout(subcommand, args, config)
  47. 1 return
  48. end
  49. 1 puts "Unknown generate command: #{subcommand}"
  50. 1 show_help
  51. 1 exit 1
  52. end
  53. 14 case subcommand
  54. when 'view'
  55. 4 generate_view(args, mode)
  56. when 'partial'
  57. 1 generate_partial(args, mode)
  58. when 'collection'
  59. 2 generate_collection(args, mode)
  60. when 'cell'
  61. 3 generate_cell(args, mode)
  62. when 'binding'
  63. 2 generate_binding(args, mode)
  64. when 'converter'
  65. 2 generate_converter(args, mode)
  66. end
  67. end
  68. 1 private
  69. 1 def parse_global_options(args)
  70. 29 options = { mode: nil }
  71. # Look for mode option and remove it from args
  72. 29 args.each_with_index do |arg, index|
  73. 30 if arg == '--mode' || arg == '-m'
  74. 9 if args[index + 1]
  75. 9 options[:mode] = args[index + 1]
  76. 9 args.delete_at(index + 1)
  77. 9 args.delete_at(index)
  78. 9 break
  79. end
  80. 21 elsif arg.start_with?('--mode=')
  81. 2 options[:mode] = arg.split('=', 2)[1]
  82. 2 args.delete_at(index)
  83. 2 break
  84. end
  85. end
  86. 29 options
  87. end
  88. 1 def generate_view(args, mode)
  89. 4 options = parse_view_options(args)
  90. 4 name = args.shift
  91. 4 if name.nil? || name.empty?
  92. 1 puts "Error: View name is required"
  93. 1 puts "Usage: kjui generate view <name> [options]"
  94. 1 exit 1
  95. end
  96. # Setup project paths
  97. 3 Core::ProjectFinder.setup_paths
  98. 3 case mode
  99. when 'xml'
  100. require_relative '../../xml/generators/view_generator'
  101. generator = KjuiTools::Xml::Generators::ViewGenerator.new(name, options)
  102. generator.generate
  103. when 'compose'
  104. 2 require_relative '../../compose/generators/view_generator'
  105. 2 generator = KjuiTools::Compose::Generators::ViewGenerator.new(name, options)
  106. 2 generator.generate
  107. else
  108. 1 puts "Error: Unknown mode: #{mode}"
  109. 1 exit 1
  110. end
  111. end
  112. 1 def generate_partial(args, mode)
  113. 1 name = args.shift
  114. 1 if name.nil? || name.empty?
  115. 1 puts "Error: Partial name is required"
  116. 1 puts "Usage: kjui generate partial <name>"
  117. 1 exit 1
  118. end
  119. case mode
  120. when 'xml'
  121. require_relative '../../xml/generators/partial_generator'
  122. generator = KjuiTools::Xml::Generators::PartialGenerator.new(name)
  123. generator.generate
  124. when 'compose'
  125. require_relative '../../compose/generators/partial_generator'
  126. generator = KjuiTools::Compose::Generators::PartialGenerator.new(name)
  127. generator.generate
  128. end
  129. end
  130. 1 def generate_collection(args, mode)
  131. 2 name = args.shift
  132. 2 if name.nil? || name.empty?
  133. 1 puts "Error: Collection name is required"
  134. 1 puts "Usage: kjui generate collection <name>"
  135. 1 exit 1
  136. end
  137. # Setup project paths
  138. 1 Core::ProjectFinder.setup_paths
  139. 1 case mode
  140. when 'xml'
  141. require_relative '../../xml/generators/collection_generator'
  142. generator = KjuiTools::Xml::Generators::CollectionGenerator.new(name)
  143. generator.generate
  144. when 'compose'
  145. require_relative '../../compose/generators/collection_generator'
  146. generator = KjuiTools::Compose::Generators::CollectionGenerator.new(name)
  147. generator.generate
  148. else
  149. 1 puts "Error: Unknown mode: #{mode}"
  150. 1 exit 1
  151. end
  152. end
  153. 1 def generate_cell(args, mode)
  154. 3 name = args.shift
  155. 3 if name.nil? || name.empty?
  156. 1 puts "Error: Cell name is required"
  157. 1 puts "Usage: kjui generate cell <name>"
  158. 1 exit 1
  159. end
  160. # Setup project paths
  161. 2 Core::ProjectFinder.setup_paths
  162. 2 case mode
  163. when 'xml'
  164. 1 puts "Cell generation is not available in XML mode"
  165. 1 exit 1
  166. when 'compose'
  167. require_relative '../../compose/generators/cell_generator'
  168. generator = KjuiTools::Compose::Generators::CellGenerator.new(name)
  169. generator.generate
  170. else
  171. 1 puts "Error: Unknown mode: #{mode}"
  172. 1 exit 1
  173. end
  174. end
  175. 1 def generate_binding(args, mode)
  176. 2 name = args.shift
  177. 2 if name.nil? || name.empty?
  178. 1 puts "Error: Binding name is required"
  179. 1 puts "Usage: kjui generate binding <name>"
  180. 1 exit 1
  181. end
  182. 1 if mode != 'xml'
  183. 1 puts "Binding generation is only available in XML mode"
  184. 1 exit 1
  185. end
  186. require_relative '../../xml/generators/binding_generator'
  187. generator = KjuiTools::Xml::Generators::BindingGenerator.new(name)
  188. generator.generate
  189. end
  190. 1 def generate_converter(args, mode)
  191. 2 unless mode == 'compose'
  192. 1 puts "Converter generation is only available in Compose mode"
  193. 1 exit 1
  194. end
  195. 1 name = args.shift
  196. 1 unless name
  197. 1 puts "Error: Please provide a component name"
  198. 1 puts "Usage: kjui generate converter <ComponentName> [options]"
  199. 1 puts "Options:"
  200. 1 puts " --container Generate as container component"
  201. 1 puts " --no-container Generate as non-container component"
  202. 1 puts " --attr KEY:TYPE Add attribute (can be used multiple times)"
  203. 1 puts " --binding KEY:TYPE Add binding attribute"
  204. 1 puts
  205. 1 puts "Examples:"
  206. 1 puts " kjui g converter MyCard --container"
  207. 1 puts " kjui g converter StatusBadge --attr text:String --attr color:Color"
  208. 1 puts " kjui g converter DataCard --binding title:String --attr icon:String"
  209. 1 exit 1
  210. end
  211. options = parse_converter_options(args)
  212. require_relative '../../compose/generators/converter_generator'
  213. generator = KjuiTools::Compose::Generators::ConverterGenerator.new(name, options)
  214. generator.generate
  215. end
  216. 1 def parse_converter_options(args)
  217. options = {
  218. 7 is_container: nil,
  219. attributes: {}
  220. }
  221. # Parse flags first
  222. 7 parser = OptionParser.new do |opts|
  223. 7 opts.on('--container', 'Generate as container component') do
  224. 1 options[:is_container] = true
  225. end
  226. 7 opts.on('--no-container', 'Generate as non-container component') do
  227. 1 options[:is_container] = false
  228. end
  229. 7 opts.on('--attr KEY:TYPE', 'Add attribute') do |attr|
  230. 3 key, type = attr.split(':')
  231. 3 if key && type
  232. 3 options[:attributes][key] = type
  233. else
  234. puts "Invalid attribute format. Use KEY:TYPE (e.g., text:String)"
  235. exit 1
  236. end
  237. end
  238. 7 opts.on('--binding KEY:TYPE', 'Add binding attribute') do |attr|
  239. 1 key, type = attr.split(':')
  240. 1 if key && type
  241. # Prefix with @ to indicate binding
  242. 1 options[:attributes]["@#{key}"] = type
  243. else
  244. puts "Invalid binding format. Use KEY:TYPE (e.g., title:String)"
  245. exit 1
  246. end
  247. end
  248. end
  249. 7 parser.parse!(args)
  250. # Parse remaining arguments as attributes (simplified syntax)
  251. 7 args.each do |arg|
  252. 3 if arg.include?(':')
  253. 3 key, type = arg.split(':', 2)
  254. 3 if key && type
  255. # Check if it's a binding (starts with @)
  256. 3 if key.start_with?('@')
  257. 1 options[:attributes][key] = type
  258. else
  259. 2 options[:attributes][key] = type
  260. end
  261. end
  262. end
  263. end
  264. 7 options
  265. end
  266. 1 def parse_view_options(args)
  267. 11 options = {
  268. root: false,
  269. mode: nil,
  270. type: nil,
  271. force: false
  272. }
  273. 11 OptionParser.new do |opts|
  274. 11 opts.on('--root', 'Generate root view/activity') do
  275. 1 options[:root] = true
  276. end
  277. 11 opts.on('--mode MODE', 'Override mode (xml, compose)') do |mode|
  278. 1 options[:mode] = mode
  279. end
  280. 11 opts.on('--type TYPE', 'View type for XML mode (activity, fragment)') do |type|
  281. 1 options[:type] = type
  282. end
  283. 11 opts.on('--activity', 'Generate as Activity (XML mode)') do
  284. 1 options[:type] = 'activity'
  285. end
  286. 11 opts.on('--fragment', 'Generate as Fragment (XML mode)') do
  287. 1 options[:type] = 'fragment'
  288. end
  289. 11 opts.on('-f', '--force', 'Force overwrite existing files') do
  290. 2 options[:force] = true
  291. end
  292. end.parse!(args)
  293. 11 options
  294. end
  295. 1 def generate_all_xml_layouts(config)
  296. require_relative '../../xml/xml_generator'
  297. require_relative '../commands/generate_xml'
  298. puts "Generating all XML layouts..."
  299. CLI::Commands::GenerateXml.run([])
  300. end
  301. 1 def generate_all_compose_views(config)
  302. 5 require_relative '../../compose/compose_builder'
  303. 5 puts "Generating all Compose views..."
  304. # Call the existing Compose builder
  305. 5 system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  306. end
  307. 1 def generate_specific_xml_layout(layout_name, args, config)
  308. require_relative '../../xml/xml_generator'
  309. require_relative '../commands/generate_xml'
  310. puts "Generating XML for layout: #{layout_name}"
  311. CLI::Commands::GenerateXml.run([layout_name] + args)
  312. end
  313. 1 def generate_specific_compose_layout(layout_name, args, config)
  314. require_relative '../../compose/compose_builder'
  315. puts "Building Compose layout: #{layout_name}"
  316. # TODO: Implement single layout generation for compose
  317. system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  318. end
  319. 1 def show_help
  320. 5 puts "Usage: kjui generate [SUBCOMMAND] [options]"
  321. 5 puts
  322. 5 puts "Global Options:"
  323. 5 puts " --mode, -m MODE Override mode (xml/compose)"
  324. 5 puts " Default: use config.json mode"
  325. 5 puts
  326. 5 puts "When in XML mode:"
  327. 5 puts " kjui generate # Generate all XML layouts"
  328. 5 puts " kjui generate test_menu # Generate specific XML layout"
  329. 5 puts
  330. 5 puts "When in Compose mode:"
  331. 5 puts " kjui generate # Generate all Compose views"
  332. 5 puts
  333. 5 puts "Subcommands:"
  334. 5 SUBCOMMANDS.each do |cmd, desc|
  335. 30 puts " #{cmd.ljust(12)} #{desc}"
  336. end
  337. 5 puts
  338. 5 puts "View Options (XML mode):"
  339. 5 puts " --activity Generate as Activity (default)"
  340. 5 puts " --fragment Generate as Fragment"
  341. 5 puts " --type TYPE Specify type (activity/fragment)"
  342. 5 puts " -f, --force Force overwrite existing files"
  343. 5 puts
  344. 5 puts "Examples:"
  345. 5 puts " kjui g # Generate all (based on config mode)"
  346. 5 puts " kjui g --mode xml # Generate all XML layouts"
  347. 5 puts " kjui g --mode compose # Generate all Compose views"
  348. 5 puts " kjui g view HomeView --mode xml --activity # Generate Activity"
  349. 5 puts " kjui g view ProfileView --mode xml --fragment # Generate Fragment"
  350. 5 puts " kjui g view MainView --mode compose # Generate Compose view"
  351. 5 puts " kjui g converter MyCard --container # Generate custom component"
  352. end
  353. end
  354. end
  355. end
  356. end

lib/cli/commands/generate_xml.rb

59.21% lines covered

76 relevant lines. 45 lines covered and 31 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../../core/config_manager'
  3. 1 require_relative '../../xml/xml_generator'
  4. 1 module CLI
  5. 1 module Commands
  6. 1 class GenerateXml
  7. 1 def self.run(args)
  8. 5 puts "🔧 KotlinJsonUI XML Generator"
  9. 5 puts "=============================="
  10. # Load configuration
  11. 5 config = ConfigManager.load_config
  12. 5 if config.nil?
  13. 1 puts "❌ Error: config.json not found"
  14. 1 puts "Run 'kjui init --mode xml' first to create configuration"
  15. 1 return 1
  16. end
  17. # Check if XML mode is configured
  18. 4 if config['mode'] != 'xml'
  19. 1 puts "❌ Error: Project is configured for #{config['mode']} mode, not XML"
  20. 1 puts "Run 'kjui init --mode xml' to reconfigure for XML mode"
  21. 1 return 1
  22. end
  23. # Parse arguments
  24. 3 layout_name = nil
  25. 3 force = false
  26. 3 i = 0
  27. 3 while i < args.length
  28. 2 case args[i]
  29. when '--layout', '-l'
  30. layout_name = args[i + 1]
  31. i += 1
  32. when '--force', '-f'
  33. force = true
  34. when '--help', '-h'
  35. 2 show_help
  36. 2 return 0
  37. else
  38. if layout_name.nil? && !args[i].start_with?('-')
  39. layout_name = args[i]
  40. end
  41. end
  42. i += 1
  43. end
  44. 1 if layout_name.nil?
  45. # Generate all layouts
  46. 1 generate_all_layouts(config, force)
  47. else
  48. # Generate specific layout
  49. generate_layout(layout_name, config, force)
  50. end
  51. 1 0
  52. rescue => e
  53. puts "❌ Error: #{e.message}"
  54. puts e.backtrace if ENV['DEBUG']
  55. 1
  56. end
  57. 1 private
  58. 1 def self.generate_all_layouts(config, force)
  59. 1 layouts_dir = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts')
  60. 1 unless Dir.exist?(layouts_dir)
  61. puts "❌ Error: Layouts directory not found: #{layouts_dir}"
  62. return
  63. end
  64. 1 json_files = Dir.glob(File.join(layouts_dir, '*.json'))
  65. 1 if json_files.empty?
  66. 1 puts "❌ No JSON layout files found in #{layouts_dir}"
  67. 1 return
  68. end
  69. puts "Found #{json_files.length} layout file(s)"
  70. puts ""
  71. success_count = 0
  72. json_files.each do |json_file|
  73. layout_name = File.basename(json_file, '.json')
  74. if should_generate?(layout_name, config, force)
  75. generator = XmlGenerator::Generator.new(layout_name, config)
  76. if generator.generate
  77. success_count += 1
  78. end
  79. else
  80. puts "⏭️ Skipping #{layout_name} (up to date)"
  81. end
  82. end
  83. puts ""
  84. puts "✅ Successfully generated #{success_count} XML layout(s)"
  85. end
  86. 1 def self.generate_layout(layout_name, config, force)
  87. # Remove .json extension if present
  88. layout_name = layout_name.sub(/\.json$/, '')
  89. if should_generate?(layout_name, config, force)
  90. generator = XmlGenerator::Generator.new(layout_name, config)
  91. if generator.generate
  92. puts "✅ Successfully generated XML for #{layout_name}"
  93. else
  94. puts "❌ Failed to generate XML for #{layout_name}"
  95. end
  96. else
  97. puts "⏭️ Layout #{layout_name} is up to date (use --force to regenerate)"
  98. end
  99. end
  100. 1 def self.should_generate?(layout_name, config, force)
  101. 5 return true if force
  102. # Check modification times
  103. 4 json_file = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json")
  104. 4 xml_file = File.join(config['project_path'], 'app', 'src', 'main', 'res', 'layout', "#{layout_name.downcase}.xml")
  105. 4 return true unless File.exist?(xml_file)
  106. 2 return true unless File.exist?(json_file)
  107. 2 File.mtime(json_file) > File.mtime(xml_file)
  108. end
  109. 1 def self.show_help
  110. 8 puts <<~HELP
  111. Usage: kjui generate-xml [layout_name] [options]
  112. Generate Android XML layouts from JSON files
  113. Arguments:
  114. layout_name Name of the layout to generate (optional)
  115. If not specified, generates all layouts
  116. Options:
  117. -l, --layout <name> Specify layout name
  118. -f, --force Force regeneration even if up to date
  119. -h, --help Show this help message
  120. Examples:
  121. kjui generate-xml # Generate all layouts
  122. kjui generate-xml test_menu # Generate specific layout
  123. kjui generate-xml -f # Force regenerate all
  124. kjui generate-xml test_menu -f # Force regenerate specific layout
  125. HELP
  126. end
  127. end
  128. end
  129. end

lib/cli/commands/hotload.rb

37.38% lines covered

107 relevant lines. 40 lines covered and 67 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require 'open3'
  5. 1 require_relative '../../hotloader/ip_monitor'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Hotload
  10. 1 def self.run(args)
  11. 6 command = args.first
  12. 6 case command
  13. when 'start', 'listen'
  14. 2 start_hotloader
  15. when 'stop'
  16. 1 stop_hotloader
  17. when 'status'
  18. 1 show_status
  19. else
  20. 2 show_help
  21. end
  22. end
  23. 1 private
  24. 1 def self.start_hotloader
  25. puts "Starting KotlinJsonUI HotLoader..."
  26. puts "================================="
  27. # Check if Node.js is installed
  28. unless system('which node > /dev/null 2>&1')
  29. puts "Error: Node.js is not installed. Please install Node.js first."
  30. puts "Visit: https://nodejs.org/"
  31. exit 1
  32. end
  33. # Find project root
  34. project_root = find_project_root
  35. hotloader_dir = File.join(File.dirname(__FILE__), '../../hotloader')
  36. # Load config to get port
  37. config_path = File.join(project_root, 'kjui.config.json')
  38. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  39. port = config.dig('hotloader', 'port') || 8081
  40. # Install npm dependencies if needed
  41. Dir.chdir(hotloader_dir) do
  42. unless Dir.exist?('node_modules')
  43. puts "Installing dependencies..."
  44. system('npm install')
  45. end
  46. end
  47. # Kill any existing processes on the port
  48. kill_port_process(port)
  49. # Start IP monitor
  50. ip_monitor = KjuiTools::Hotloader::IpMonitor.new(project_root)
  51. ip_monitor.start
  52. # Get current IP
  53. ip = get_local_ip
  54. puts "\nLocal IP: #{ip}"
  55. puts "Port: #{port}"
  56. # Start Node.js server
  57. puts "\nStarting server..."
  58. Dir.chdir(hotloader_dir) do
  59. ENV['HOST'] = '0.0.0.0'
  60. ENV['PORT'] = port.to_s
  61. ENV['PROJECT_ROOT'] = project_root
  62. # Start server in foreground
  63. system('node server.js')
  64. end
  65. # Stop IP monitor when server stops
  66. ip_monitor.stop
  67. end
  68. 1 def self.stop_hotloader
  69. puts "Stopping KotlinJsonUI HotLoader..."
  70. # Load config to get port
  71. project_root = find_project_root
  72. config_path = File.join(project_root, 'kjui.config.json')
  73. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  74. port = config.dig('hotloader', 'port') || 8081
  75. # Kill Node.js server
  76. kill_port_process(port)
  77. # Kill any node processes running server.js
  78. system("pkill -f 'node.*server.js'")
  79. puts "HotLoader stopped"
  80. end
  81. 1 def self.show_status
  82. puts "KotlinJsonUI HotLoader Status"
  83. puts "============================="
  84. # Load config to get port
  85. project_root = find_project_root
  86. config_path = File.join(project_root, 'kjui.config.json')
  87. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  88. port = config.dig('hotloader', 'port') || 8081
  89. # Check if server is running
  90. if port_in_use?(port)
  91. puts "Status: ✅ Running"
  92. # Try to get server info
  93. begin
  94. require 'net/http'
  95. require 'uri'
  96. ip = get_local_ip
  97. uri = URI.parse("http://#{ip}:#{port}/")
  98. response = Net::HTTP.get_response(uri)
  99. if response.code == '200'
  100. info = JSON.parse(response.body)
  101. puts "Project: #{info['projectRoot']}"
  102. puts "Connected clients: #{info['connectedClients']}"
  103. end
  104. rescue => e
  105. puts "Server is running but couldn't get details"
  106. end
  107. else
  108. puts "Status: ❌ Not running"
  109. end
  110. # Show configuration
  111. if config['hotloader']
  112. puts "\nConfiguration:"
  113. puts "IP: #{config['hotloader']['ip']}"
  114. puts "Port: #{config['hotloader']['port']}"
  115. puts "Enabled: #{config['hotloader']['enabled']}"
  116. end
  117. end
  118. 1 def self.show_help
  119. 5 puts <<~HELP
  120. KotlinJsonUI HotLoader Commands
  121. ===============================
  122. Usage: kjui hotload <command>
  123. Commands:
  124. start, listen - Start the hotloader server
  125. stop - Stop the hotloader server
  126. status - Show server status
  127. The hotloader enables real-time UI updates during development.
  128. It watches for changes in Layouts/ and Styles/ directories and
  129. automatically rebuilds and reloads the UI in your Android app.
  130. Example:
  131. kjui hotload start # Start development server
  132. kjui hotload stop # Stop server
  133. kjui hotload status # Check if server is running
  134. HELP
  135. end
  136. 1 def self.find_project_root(start_path = Dir.pwd)
  137. 4 current = start_path
  138. 4 while current != '/'
  139. # Check for kjui.config.json
  140. 5 if File.exist?(File.join(current, 'kjui.config.json'))
  141. 2 return current
  142. end
  143. # Check for Android project files
  144. 3 if File.exist?(File.join(current, 'build.gradle.kts')) ||
  145. File.exist?(File.join(current, 'settings.gradle.kts'))
  146. 2 return current
  147. end
  148. 1 current = File.dirname(current)
  149. end
  150. Dir.pwd
  151. end
  152. 1 def self.get_local_ip
  153. 1 require 'socket'
  154. # Try common interface names
  155. 1 interfaces = ['wlan0', 'wlp2s0', 'en0', 'en1', 'eth0']
  156. 1 interfaces.each do |interface|
  157. 3 Socket.getifaddrs.each do |ifaddr|
  158. 109 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  159. 1 ip = ifaddr.addr.ip_address
  160. 1 return ip unless ip.start_with?('127.')
  161. end
  162. end
  163. end
  164. # Fallback
  165. Socket.ip_address_list.find { |ai| ai.ipv4? && !ai.ipv4_loopback? }&.ip_address || '127.0.0.1'
  166. rescue
  167. '127.0.0.1'
  168. end
  169. 1 def self.port_in_use?(port)
  170. 1 system("lsof -i:#{port} > /dev/null 2>&1")
  171. end
  172. 1 def self.kill_port_process(port)
  173. if port_in_use?(port)
  174. puts "Killing existing process on port #{port}..."
  175. system("lsof -ti:#{port} | xargs kill -9 2>/dev/null")
  176. sleep 1
  177. end
  178. end
  179. end
  180. end
  181. end
  182. end

lib/cli/commands/init.rb

68.29% lines covered

82 relevant lines. 56 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require 'json'
  5. 1 require_relative '../../core/config_manager'
  6. 1 require_relative '../../core/project_finder'
  7. 1 module KjuiTools
  8. 1 module CLI
  9. 1 module Commands
  10. 1 class Init
  11. 1 def run(args)
  12. 10 options = parse_options(args)
  13. # Check if MODE file exists (set by installer)
  14. 9 installer_mode = nil
  15. 9 mode_file = File.join(File.dirname(__FILE__), '../../../../MODE')
  16. 9 if File.exist?(mode_file)
  17. installer_mode = File.read(mode_file).strip
  18. end
  19. # Detect or use specified mode
  20. 9 mode = options[:mode] || installer_mode || Core::ConfigManager.detect_mode
  21. 9 puts "Initializing KotlinJsonUI project in #{mode} mode..."
  22. # Create config file only - directories will be created by 'setup' command
  23. 9 create_config_file(mode)
  24. 9 puts "Initialization complete!"
  25. 9 puts
  26. 9 puts "Next steps:"
  27. 9 puts " 1. Edit kjui.config.json to customize paths if needed"
  28. 9 puts " 2. Run 'kjui setup' to create directories and base files"
  29. 9 puts " 3. Run 'kjui g view HomeView' to generate your first view"
  30. end
  31. 1 private
  32. 1 def parse_options(args)
  33. 10 options = {}
  34. 10 OptionParser.new do |opts|
  35. 10 opts.banner = "Usage: kjui init [options]"
  36. 10 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  37. 'Initialize mode (all, xml, compose)') do |mode|
  38. 8 options[:mode] = mode
  39. end
  40. 10 opts.on('-h', '--help', 'Show this help message') do
  41. 1 puts opts
  42. 1 exit
  43. end
  44. end.parse!(args)
  45. 9 options
  46. end
  47. 1 def create_config_file(mode)
  48. 9 config_file = 'kjui.config.json'
  49. 9 if File.exist?(config_file)
  50. 1 puts "Config file already exists: #{config_file}"
  51. # Check if source_directory needs to be updated
  52. 1 existing_config = JSON.parse(File.read(config_file))
  53. 1 if existing_config['source_directory'].to_s.empty?
  54. Core::ProjectFinder.setup_paths
  55. # Auto-detect source directory without checking config
  56. project_dir = Core::ProjectFinder.project_dir
  57. # If project_dir is nil, fallback to finding gradle files
  58. if project_dir.nil?
  59. gradle_file = Dir.glob('build.gradle*').first || Dir.glob('../build.gradle*').first
  60. project_dir = gradle_file ? File.dirname(File.expand_path(gradle_file)) : Dir.pwd
  61. end
  62. common_names = ['app/src/main', 'src/main', 'src', File.basename(project_dir)]
  63. source_dir = nil
  64. common_names.each do |name|
  65. path = File.join(project_dir, name)
  66. if Dir.exist?(path)
  67. source_dir = name
  68. break
  69. end
  70. end
  71. if source_dir && !source_dir.empty?
  72. existing_config['source_directory'] = source_dir
  73. File.write(config_file, JSON.pretty_generate(existing_config))
  74. puts "Updated source_directory to: #{source_dir}"
  75. end
  76. end
  77. 1 return
  78. end
  79. # Find project info
  80. 8 Core::ProjectFinder.setup_paths
  81. # Get project name from settings.gradle or current directory
  82. 8 project_name = get_project_name_from_gradle || File.basename(Dir.pwd)
  83. # Create base config based on mode
  84. 8 if mode == 'compose'
  85. # Detect package name
  86. 5 package_name = Core::ProjectFinder.package_name
  87. # Compose-specific config with appropriate defaults
  88. # Detect if we're in a module or main app
  89. 5 source_dir = if Dir.exist?('src/main')
  90. 'src/main'
  91. 5 elsif Dir.exist?('app/src/main')
  92. 'app/src/main'
  93. else
  94. 5 Core::ProjectFinder.find_source_directory || 'src/main'
  95. end
  96. config = {
  97. 5 'mode' => mode,
  98. 'project_name' => project_name,
  99. 'source_directory' => source_dir,
  100. 'layouts_directory' => 'assets/Layouts',
  101. 'styles_directory' => 'assets/Styles',
  102. 'data_directory' => "kotlin/#{package_name.gsub('.', '/')}/data",
  103. 'viewmodel_directory' => "kotlin/#{package_name.gsub('.', '/')}/viewmodels",
  104. 'view_directory' => "kotlin/#{package_name.gsub('.', '/')}/views",
  105. 'extension_directory' => "kotlin/#{package_name.gsub('.', '/')}/extensions",
  106. 'adapter_directory' => "kotlin/#{package_name.gsub('.', '/')}/adapters",
  107. 'resource_manager_directory' => "kotlin/#{package_name.gsub('.', '/')}/generated",
  108. 'package_name' => package_name,
  109. 'string_files' => [
  110. 'res/values/strings.xml',
  111. 'res/values-ja/strings.xml'
  112. ],
  113. 'use_network' => true, # Compose mode can use network for API calls
  114. 'hotloader' => {
  115. 'ip' => '127.0.0.1',
  116. 'port' => 8081,
  117. 'watch_directories' => ['assets/Layouts', 'assets/Styles']
  118. }
  119. }
  120. else
  121. # XML mode or all mode config
  122. config = {
  123. 3 'mode' => mode,
  124. 'project_name' => project_name,
  125. 'project_file_name' => project_name,
  126. 'source_directory' => Core::ProjectFinder.find_source_directory || 'app/src/main',
  127. 'layouts_directory' => 'res/raw/layouts',
  128. 'styles_directory' => 'res/raw/styles',
  129. 'view_directory' => 'java/com/example/app/ui',
  130. 'data_directory' => 'java/com/example/app/data',
  131. 'viewmodel_directory' => 'java/com/example/app/viewmodel',
  132. 'bindings_directory' => 'java/com/example/app/bindings',
  133. 'extension_directory' => 'java/com/example/app/extensions',
  134. 'adapter_directory' => 'java/com/example/app/adapters',
  135. 'resource_manager_directory' => 'java/com/example/app/generated',
  136. 'string_files' => [
  137. 'res/values/strings.xml',
  138. 'res/values-ja/strings.xml'
  139. ],
  140. 'use_network' => true,
  141. 'hotloader' => {
  142. 'ip' => '127.0.0.1',
  143. 'port' => 8081,
  144. 'watch_directories' => ['res/raw/layouts', 'res/raw/styles']
  145. }
  146. }
  147. # Add Compose config if mode is 'all'
  148. 3 if mode == 'all'
  149. config['compose'] = {
  150. 'output_directory' => 'java/com/example/app/generated'
  151. }
  152. end
  153. end
  154. 8 File.write(config_file, JSON.pretty_generate(config))
  155. 8 puts "Created config file: #{config_file}"
  156. end
  157. 1 def get_project_name_from_gradle
  158. # Try settings.gradle.kts first
  159. 8 if File.exist?('settings.gradle.kts')
  160. content = File.read('settings.gradle.kts')
  161. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  162. return $1
  163. end
  164. end
  165. # Try settings.gradle
  166. 8 if File.exist?('settings.gradle')
  167. content = File.read('settings.gradle')
  168. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  169. return $1
  170. end
  171. end
  172. nil
  173. end
  174. end
  175. end
  176. end
  177. end

lib/cli/commands/setup.rb

73.21% lines covered

56 relevant lines. 41 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Setup
  10. 1 def run(args)
  11. 6 options = parse_options(args)
  12. # Check and install dependencies first
  13. 6 ensure_dependencies_installed
  14. # Setup project paths
  15. 6 Core::ProjectFinder.setup_paths
  16. # Load config to determine mode
  17. 6 config = Core::ConfigManager.load_config
  18. 6 mode = config['mode'] || 'compose'
  19. 6 puts "Setting up KotlinJsonUI project in #{mode} mode..."
  20. # Setup based on mode
  21. 6 case mode
  22. when 'compose'
  23. 3 setup_compose_project
  24. when 'xml'
  25. 2 setup_xml_project
  26. when 'all'
  27. 1 setup_xml_project
  28. 1 setup_compose_project
  29. end
  30. 6 puts "\nSetup complete!"
  31. 6 if mode == 'compose'
  32. 3 puts "Next steps:"
  33. 3 puts " 1. Create your layouts in the assets/Layouts directory"
  34. 3 puts " 2. Run 'kjui convert' to generate Compose code"
  35. 3 puts " 3. Build your project with Gradle"
  36. else
  37. 3 puts "Next steps:"
  38. 3 puts " 1. Run 'kjui g view HomeView' to generate your first view"
  39. 3 puts " 2. Build your project with Gradle"
  40. end
  41. end
  42. 1 private
  43. 1 def ensure_dependencies_installed
  44. # Check if Gemfile.lock exists
  45. kjui_tools_dir = File.expand_path('../../../..', __FILE__)
  46. gemfile_lock = File.join(kjui_tools_dir, 'Gemfile.lock')
  47. unless File.exist?(gemfile_lock)
  48. puts "Installing kjui_tools dependencies..."
  49. Dir.chdir(kjui_tools_dir) do
  50. success = system('bundle install')
  51. unless success
  52. puts "Warning: Failed to install some dependencies"
  53. puts "You may need to install them manually with: cd kjui_tools && bundle install"
  54. end
  55. end
  56. end
  57. end
  58. 1 def parse_options(args)
  59. 9 options = {}
  60. 9 OptionParser.new do |opts|
  61. 9 opts.banner = "Usage: kjui setup [options]"
  62. 9 opts.on('-h', '--help', 'Show this help message') do
  63. 2 puts opts
  64. 2 exit
  65. end
  66. end.parse!(args)
  67. 7 options
  68. end
  69. 1 def setup_compose_project
  70. require_relative '../../compose/setup/compose_setup'
  71. # Use the Compose-specific setup
  72. setup = ::KjuiTools::Compose::Setup::ComposeSetup.new(Core::ProjectFinder.project_file_path)
  73. setup.run_full_setup
  74. end
  75. 1 def setup_xml_project
  76. require_relative '../../xml/setup/xml_setup'
  77. # Use the XML-specific setup
  78. setup = ::KjuiTools::Xml::Setup::XmlSetup.new(Core::ProjectFinder.project_file_path)
  79. setup.run_full_setup
  80. end
  81. end
  82. end
  83. end
  84. end

lib/cli/main.rb

89.29% lines covered

28 relevant lines. 25 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'version'
  3. 1 require_relative 'commands/init'
  4. 1 require_relative 'commands/setup'
  5. 1 require_relative 'commands/build'
  6. 1 require_relative 'commands/generate'
  7. 1 require_relative 'commands/hotload'
  8. 1 module KjuiTools
  9. 1 module CLI
  10. 1 class Main
  11. 1 def self.run(args)
  12. 19 command = args.shift
  13. 19 case command
  14. when 'init'
  15. 1 Commands::Init.new.run(args)
  16. when 'setup'
  17. 1 Commands::Setup.new.run(args)
  18. when 'generate', 'g'
  19. 2 Commands::Generate.new.run(args)
  20. when 'build', 'b'
  21. 2 Commands::Build.new.run(args)
  22. when 'hotload', 'hot'
  23. 2 Commands::Hotload.run(args)
  24. when 'watch', 'w'
  25. 2 puts "Watch command not yet implemented"
  26. when 'version', 'v', '--version', '-v'
  27. 4 puts "KotlinJsonUI Tools version #{VERSION}"
  28. when 'help', '--help', '-h', nil
  29. 4 show_help
  30. else
  31. 1 puts "Unknown command: #{command}"
  32. 1 show_help
  33. 1 exit 1
  34. end
  35. rescue StandardError => e
  36. puts "Error: #{e.message}"
  37. puts e.backtrace if ENV['DEBUG']
  38. exit 1
  39. end
  40. 1 def self.show_help
  41. 11 puts <<~HELP
  42. KotlinJsonUI Tools - JSON-based UI framework for Android
  43. Usage: kjui <command> [options]
  44. Commands:
  45. init Initialize a new KotlinJsonUI project
  46. generate, g Generate views and components
  47. setup Set up project dependencies
  48. build, b Build the project
  49. hotload, hot Start/stop hotload server for real-time updates
  50. watch, w Watch for file changes
  51. version, v Show version information
  52. help Show this help message
  53. Examples:
  54. kjui init --mode compose Initialize a Jetpack Compose project
  55. kjui init --mode xml Initialize an XML-based project
  56. kjui g view HomeView Generate a new view
  57. For more information on a specific command:
  58. kjui <command> --help
  59. HELP
  60. end
  61. end
  62. end
  63. end

lib/cli/version.rb

100.0% lines covered

3 relevant lines. 3 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module CLI
  4. 1 VERSION = '1.0.0'
  5. end
  6. end

lib/compose/build_cache_manager.rb

74.73% lines covered

91 relevant lines. 68 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'pathname'
  5. 1 require 'digest'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 class BuildCacheManager
  9. 1 def initialize(source_path)
  10. 26 @source_path = source_path
  11. 26 @cache_dir = File.join(source_path, '.kjui_cache')
  12. 26 @last_updated_file = File.join(@cache_dir, 'last_updated.json')
  13. 26 @including_files_cache = File.join(@cache_dir, 'including_files.json')
  14. 26 @style_dependencies_cache = File.join(@cache_dir, 'style_dependencies.json')
  15. # Create cache directory if it doesn't exist
  16. 26 FileUtils.mkdir_p(@cache_dir) unless File.exist?(@cache_dir)
  17. end
  18. 1 def load_last_updated
  19. 11 return {} unless File.exist?(@last_updated_file)
  20. 2 JSON.parse(File.read(@last_updated_file))
  21. rescue JSON::ParserError
  22. 1 {}
  23. end
  24. 1 def load_last_including_files
  25. 10 return {} unless File.exist?(@including_files_cache)
  26. 1 JSON.parse(File.read(@including_files_cache))
  27. rescue JSON::ParserError
  28. {}
  29. end
  30. 1 def load_style_dependencies
  31. 9 return {} unless File.exist?(@style_dependencies_cache)
  32. JSON.parse(File.read(@style_dependencies_cache))
  33. rescue JSON::ParserError
  34. {}
  35. end
  36. 1 def needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  37. 6 file_name = File.basename(json_file, '.json')
  38. # Check if file exists in last_updated
  39. 6 return true unless last_updated[file_name]
  40. # Check if file has been modified
  41. 2 file_mtime = File.mtime(json_file).to_i
  42. 2 return true if file_mtime > last_updated[file_name]['mtime'].to_i
  43. # Check if any included files have been modified
  44. 1 if last_including_files[file_name]
  45. last_including_files[file_name].each do |included_file|
  46. included_path = File.join(layouts_dir, "#{included_file}.json")
  47. if File.exist?(included_path)
  48. included_mtime = File.mtime(included_path).to_i
  49. return true if included_mtime > last_updated[file_name]['mtime'].to_i
  50. end
  51. end
  52. end
  53. # Check if any style dependencies have been modified
  54. 1 if style_dependencies[file_name]
  55. styles_dir = File.join(@source_path, 'assets', 'Styles')
  56. style_dependencies[file_name].each do |style_file|
  57. style_path = File.join(styles_dir, "#{style_file}.json")
  58. if File.exist?(style_path)
  59. style_mtime = File.mtime(style_path).to_i
  60. return true if style_mtime > last_updated[file_name]['mtime'].to_i
  61. end
  62. end
  63. end
  64. # Check if any file that includes this file has been modified
  65. 1 last_including_files.each do |parent_file, includes|
  66. if includes && includes.include?(file_name)
  67. parent_path = File.join(layouts_dir, "#{parent_file}.json")
  68. if File.exist?(parent_path)
  69. parent_mtime = File.mtime(parent_path).to_i
  70. return true if parent_mtime > last_updated[file_name]['mtime'].to_i
  71. end
  72. end
  73. end
  74. 1 false
  75. end
  76. 1 def extract_includes(json_data, includes = Set.new)
  77. 16 if json_data.is_a?(Hash)
  78. # Check for include
  79. 15 if json_data['include']
  80. 6 includes.add(json_data['include'])
  81. end
  82. # Process children
  83. 15 if json_data['child']
  84. 6 if json_data['child'].is_a?(Array)
  85. 4 json_data['child'].each do |child|
  86. 5 extract_includes(child, includes)
  87. end
  88. else
  89. 2 extract_includes(json_data['child'], includes)
  90. end
  91. end
  92. 1 elsif json_data.is_a?(Array)
  93. 1 json_data.each do |item|
  94. 2 extract_includes(item, includes)
  95. end
  96. end
  97. 16 includes.to_a
  98. end
  99. 1 def extract_styles(json_data, styles = Set.new)
  100. 10 if json_data.is_a?(Hash)
  101. # Check for style attribute
  102. 10 if json_data['style']
  103. 3 styles.add(json_data['style'])
  104. end
  105. # Process children
  106. 10 if json_data['child']
  107. 4 if json_data['child'].is_a?(Array)
  108. 4 json_data['child'].each do |child|
  109. 5 extract_styles(child, styles)
  110. end
  111. else
  112. extract_styles(json_data['child'], styles)
  113. end
  114. end
  115. elsif json_data.is_a?(Array)
  116. json_data.each do |item|
  117. extract_styles(item, styles)
  118. end
  119. end
  120. 10 styles.to_a
  121. end
  122. 1 def save_cache(including_files, style_dependencies)
  123. # Update last_updated with current timestamps
  124. 4 last_updated = {}
  125. # Get all processed files
  126. 4 all_files = (including_files.keys + style_dependencies.keys).uniq
  127. 4 all_files.each do |file_name|
  128. 1 layouts_dir = File.join(@source_path, 'assets', 'Layouts')
  129. 1 json_file = File.join(layouts_dir, "#{file_name}.json")
  130. 1 if File.exist?(json_file)
  131. 1 last_updated[file_name] = {
  132. 'mtime' => File.mtime(json_file).to_i,
  133. 'hash' => Digest::MD5.hexdigest(File.read(json_file))
  134. }
  135. end
  136. end
  137. # Save all cache files
  138. 4 File.write(@last_updated_file, JSON.pretty_generate(last_updated))
  139. 4 File.write(@including_files_cache, JSON.pretty_generate(including_files))
  140. 4 File.write(@style_dependencies_cache, JSON.pretty_generate(style_dependencies))
  141. end
  142. 1 def clean_cache
  143. 2 FileUtils.rm_rf(@cache_dir)
  144. 2 FileUtils.mkdir_p(@cache_dir)
  145. end
  146. end
  147. end
  148. end

lib/compose/components/blurview_component.rb

76.47% lines covered

34 relevant lines. 26 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class BlurviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # BlurView in Compose requires a special modifier or library
  10. # For now, we'll create a semi-transparent overlay as a fallback
  11. 3 code = indent("Box(", depth)
  12. # Build modifiers
  13. 3 modifiers = []
  14. 3 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  15. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  16. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  17. # Add blur effect
  18. 3 blur_radius = json_data['blurRadius'] || 10
  19. # Try to use real blur modifier (available in Compose 1.3+)
  20. 3 required_imports&.add(:blur)
  21. 3 modifiers << ".blur(#{blur_radius}.dp)"
  22. # Background color
  23. 3 if json_data['backgroundColor']
  24. bg_color = json_data['backgroundColor']
  25. opacity = json_data['opacity'] || 0.8
  26. modifiers << ".background(Helpers::ResourceResolver.process_color('#{bg_color}', required_imports).copy(alpha = #{opacity}f))"
  27. end
  28. # Add corner radius if specified
  29. 3 if json_data['cornerRadius']
  30. required_imports&.add(:shape)
  31. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  32. end
  33. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  34. 3 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  35. 3 code += "\n" + indent(") {", depth)
  36. # Process children
  37. 3 children = json_data['child'] || []
  38. 3 children = [children] unless children.is_a?(Array)
  39. # Return structure for parent to process children
  40. 3 { code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
  41. end
  42. 1 private
  43. 1 def self.indent(text, level)
  44. 9 return text if level == 0
  45. spaces = ' ' * level
  46. text.split("\n").map { |line|
  47. line.empty? ? line : spaces + line
  48. }.join("\n")
  49. end
  50. end
  51. end
  52. end
  53. end

lib/compose/components/button_component.rb

97.06% lines covered

102 relevant lines. 99 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ButtonComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Button uses 'text' attribute per SwiftJsonUI spec
  10. 26 text = Helpers::ResourceResolver.process_text(json_data['text'] || 'Button', required_imports)
  11. 26 code = indent("Button(", depth)
  12. # Handle click events
  13. # onclick (lowercase) -> selector format (string only)
  14. # onClick (camelCase) -> binding format only (@{functionName})
  15. 26 if json_data['onclick']
  16. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
  17. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  18. 25 elsif json_data['onClick']
  19. handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
  20. code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  21. else
  22. 25 code += "\n" + indent("onClick = { }", depth + 1)
  23. end
  24. # Build modifiers (only margins and size, not padding)
  25. 26 modifiers = []
  26. 26 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  27. 26 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  28. # Format modifiers only if there are modifiers
  29. 26 if modifiers.any?
  30. 3 code += ","
  31. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  32. end
  33. # Add shape with cornerRadius if specified
  34. 26 if json_data['cornerRadius']
  35. 1 required_imports&.add(:shape)
  36. 1 code += ",\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp)", depth + 1)
  37. end
  38. # Add contentPadding for internal padding
  39. # Support both 'padding' (number), 'paddings' (array), and individual padding attributes
  40. 26 padding_data = json_data['paddings'] || json_data['padding']
  41. 26 if padding_data || json_data['paddingTop'] || json_data['paddingBottom'] ||
  42. json_data['paddingLeft'] || json_data['paddingRight'] || json_data['paddingStart'] ||
  43. json_data['paddingEnd'] || json_data['paddingHorizontal'] || json_data['paddingVertical']
  44. 7 required_imports&.add(:button_padding)
  45. 7 padding_values = []
  46. 7 if padding_data
  47. # Handle paddings array or padding number
  48. 5 if padding_data.is_a?(Array)
  49. 4 case padding_data.length
  50. when 1
  51. # One value: all sides
  52. 1 padding_values << "#{padding_data[0]}.dp"
  53. when 2
  54. # Two values: [vertical, horizontal]
  55. 1 padding_values << "vertical = #{padding_data[0]}.dp"
  56. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  57. when 3
  58. # Three values: [top, horizontal, bottom]
  59. 1 padding_values << "top = #{padding_data[0]}.dp"
  60. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  61. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  62. when 4
  63. # Four values: [top, right, bottom, left]
  64. 1 padding_values << "top = #{padding_data[0]}.dp"
  65. 1 padding_values << "end = #{padding_data[1]}.dp"
  66. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  67. 1 padding_values << "start = #{padding_data[3]}.dp"
  68. end
  69. else
  70. # Single number: all sides
  71. 1 padding_values << "#{padding_data}.dp"
  72. end
  73. else
  74. # Handle individual padding attributes
  75. 2 top_padding = json_data['paddingTop'] || json_data['paddingVertical'] || 0
  76. 2 bottom_padding = json_data['paddingBottom'] || json_data['paddingVertical'] || 0
  77. 2 start_padding = json_data['paddingStart'] || json_data['paddingLeft'] || json_data['paddingHorizontal'] || 0
  78. 2 end_padding = json_data['paddingEnd'] || json_data['paddingRight'] || json_data['paddingHorizontal'] || 0
  79. 2 if top_padding == bottom_padding && start_padding == end_padding && top_padding == start_padding
  80. # All same, use single value
  81. padding_values << "#{top_padding}.dp" if top_padding > 0
  82. 2 elsif top_padding == bottom_padding && start_padding == end_padding
  83. # Different horizontal and vertical
  84. 1 padding_values << "horizontal = #{start_padding}.dp" if start_padding > 0
  85. 1 padding_values << "vertical = #{top_padding}.dp" if top_padding > 0
  86. else
  87. # All different, need to specify each
  88. 1 padding_values << "start = #{start_padding}.dp" if start_padding > 0
  89. 1 padding_values << "top = #{top_padding}.dp" if top_padding > 0
  90. 1 padding_values << "end = #{end_padding}.dp" if end_padding > 0
  91. 1 padding_values << "bottom = #{bottom_padding}.dp" if bottom_padding > 0
  92. end
  93. end
  94. 7 if padding_values.any?
  95. 7 code += ",\n" + indent("contentPadding = PaddingValues(#{padding_values.join(', ')})", depth + 1)
  96. end
  97. end
  98. # Button colors including normal, disabled, and pressed states
  99. 26 if json_data['background'] || json_data['disabledBackground'] || json_data['disabledFontColor'] || json_data['hilightColor']
  100. 4 required_imports&.add(:button_colors)
  101. 4 colors_code = "colors = ButtonDefaults.buttonColors("
  102. 4 color_params = []
  103. 4 if json_data['background']
  104. 1 background_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  105. 1 color_params << "containerColor = #{background_color}"
  106. end
  107. 4 if json_data['disabledBackground']
  108. 1 disabled_bg_color = Helpers::ResourceResolver.process_color(json_data['disabledBackground'], required_imports)
  109. 1 color_params << "disabledContainerColor = #{disabled_bg_color}"
  110. end
  111. 4 if json_data['disabledFontColor']
  112. 1 disabled_font_color = Helpers::ResourceResolver.process_color(json_data['disabledFontColor'], required_imports)
  113. 1 color_params << "disabledContentColor = #{disabled_font_color}"
  114. end
  115. # Note: hilightColor (pressed state) isn't directly supported in Material3 ButtonDefaults
  116. # We'd need a custom button implementation or InteractionSource for true pressed state
  117. 4 if json_data['hilightColor']
  118. 1 color_params << "// hilightColor: #{json_data['hilightColor']} - Use InteractionSource for pressed state"
  119. end
  120. 4 if color_params.any?
  121. 8 colors_code += "\n" + color_params.map { |param| indent(param, depth + 2) }.join(",\n")
  122. 4 colors_code += "\n" + indent(")", depth + 1)
  123. 4 code += ",\n" + indent(colors_code, depth + 1)
  124. end
  125. end
  126. # Handle enabled attribute
  127. 26 if json_data.key?('enabled')
  128. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  129. # Data binding for enabled
  130. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  131. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  132. else
  133. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  134. end
  135. end
  136. 26 code += "\n" + indent(") {", depth)
  137. 26 code += "\n" + indent("Text(#{text})", depth + 1)
  138. # Apply text attributes if specified
  139. 26 if json_data['fontSize'] || json_data['fontColor']
  140. 2 text_code = "\n" + indent("Text(", depth + 1)
  141. 2 text_code += "\n" + indent("text = #{text},", depth + 2)
  142. 2 if json_data['fontSize']
  143. 1 text_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 2)
  144. end
  145. 2 if json_data['fontColor']
  146. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  147. 1 text_code += "\n" + indent("color = #{color_value},", depth + 2) if color_value
  148. end
  149. 2 text_code += "\n" + indent(")", depth + 1)
  150. 2 code = code.sub(/Text\(#{Regexp.escape(text)}\)/, text_code.strip)
  151. end
  152. 26 code += "\n" + indent("}", depth)
  153. 26 code
  154. end
  155. 1 private
  156. 1 def self.indent(text, level)
  157. 164 return text if level == 0
  158. 85 spaces = ' ' * level
  159. 85 text.split("\n").map { |line|
  160. 93 line.empty? ? line : spaces + line
  161. }.join("\n")
  162. end
  163. end
  164. end
  165. end
  166. end

lib/compose/components/checkbox_component.rb

37.75% lines covered

151 relevant lines. 57 lines covered and 94 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. # CheckBox Component Generator
  8. # CheckBox is the primary component name. Check is supported as an alias for backward compatibility.
  9. # Both "CheckBox" and "Check" JSON types map to this component.
  10. 1 class CheckboxComponent
  11. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  12. # CheckBox uses 'isOn', 'checked', or 'bind' for binding
  13. # Priority: isOn > checked > bind
  14. 7 state_attr = json_data['isOn'] || json_data['checked']
  15. 7 checked = if state_attr
  16. 2 if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}"
  19. else
  20. 1 state_attr.to_s
  21. end
  22. 5 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. variable = $1
  24. "data.#{variable}"
  25. else
  26. 5 'false'
  27. end
  28. 7 has_label = json_data['label'] || json_data['text']
  29. 7 has_custom_icon = json_data['icon'] || json_data['selectedIcon']
  30. # If custom icons are specified, use IconToggleButton instead of Checkbox
  31. 7 if has_custom_icon
  32. return generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
  33. end
  34. 7 if has_label
  35. # Checkbox with label
  36. code = indent("Row(", depth)
  37. code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
  38. # Build modifiers for Row
  39. modifiers = []
  40. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  43. code += "\n" + indent(") {", depth)
  44. # Checkbox
  45. code += "\n" + indent("Checkbox(", depth + 1)
  46. code += "\n" + indent("checked = #{checked},", depth + 2)
  47. # onCheckedChange handler
  48. binding_variable = nil
  49. state_attr_val = json_data['isOn'] || json_data['checked']
  50. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  51. binding_variable = $1
  52. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  53. binding_variable = $1
  54. end
  55. if json_data['onValueChange']
  56. # onValueChange (camelCase) -> binding format only (@{functionName})
  57. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  58. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  59. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
  60. else
  61. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 2)
  62. end
  63. elsif binding_variable
  64. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
  65. else
  66. code += "\n" + indent("onCheckedChange = { }", depth + 2)
  67. end
  68. code += "\n" + indent(")", depth + 1)
  69. # Spacer with configurable spacing
  70. spacing = json_data['spacing'] || 8
  71. code += "\n" + indent("Spacer(modifier = Modifier.width(#{spacing}.dp))", depth + 1)
  72. # Label text with font attributes
  73. label_text = json_data['label'] || json_data['text']
  74. text_params = ["text = \"#{label_text}\""]
  75. if json_data['fontSize']
  76. text_params << "fontSize = #{json_data['fontSize']}.sp"
  77. end
  78. if json_data['fontColor']
  79. font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  80. text_params << "color = #{font_color}"
  81. end
  82. if json_data['font']
  83. font_weight = json_data['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
  84. text_params << "fontWeight = #{font_weight}"
  85. end
  86. if text_params.size == 1
  87. code += "\n" + indent("Text(\"#{label_text}\")", depth + 1)
  88. else
  89. code += "\n" + indent("Text(", depth + 1)
  90. code += "\n" + text_params.map { |param| indent(param, depth + 2) }.join(",\n")
  91. code += "\n" + indent(")", depth + 1)
  92. end
  93. code += "\n" + indent("}", depth)
  94. else
  95. # Checkbox without label
  96. 7 code = indent("Checkbox(", depth)
  97. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  98. # onCheckedChange handler
  99. 7 binding_variable = nil
  100. 7 state_attr_val = json_data['isOn'] || json_data['checked']
  101. 7 if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  102. 1 binding_variable = $1
  103. 6 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  104. binding_variable = $1
  105. end
  106. 7 if json_data['onValueChange']
  107. # onValueChange (camelCase) -> binding format only (@{functionName})
  108. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  109. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  110. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
  111. else
  112. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
  113. end
  114. 7 elsif binding_variable
  115. 1 code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
  116. else
  117. 6 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  118. end
  119. # Build modifiers
  120. 7 modifiers = []
  121. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  122. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  123. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  124. # Add weight modifier if in Row or Column
  125. 7 if parent_type == 'Row' || parent_type == 'Column'
  126. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  127. end
  128. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  129. # Checkbox colors
  130. 7 if json_data['checkColor'] || json_data['uncheckedColor']
  131. 1 required_imports&.add(:checkbox_colors)
  132. 1 colors_params = []
  133. 1 if json_data['checkColor']
  134. 1 checked_color = Helpers::ResourceResolver.process_color(json_data['checkColor'], required_imports)
  135. 1 colors_params << "checkedColor = #{checked_color}"
  136. end
  137. 1 if json_data['uncheckedColor']
  138. unchecked_color = Helpers::ResourceResolver.process_color(json_data['uncheckedColor'], required_imports)
  139. colors_params << "uncheckedColor = #{unchecked_color}"
  140. end
  141. 1 if colors_params.any?
  142. 1 code += ",\n" + indent("colors = CheckboxDefaults.colors(", depth + 1)
  143. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  144. 1 code += "\n" + indent(")", depth + 1)
  145. end
  146. end
  147. # Handle enabled attribute
  148. 7 if json_data.key?('enabled')
  149. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  150. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  151. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  152. else
  153. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  154. end
  155. end
  156. 7 code += "\n" + indent(")", depth)
  157. end
  158. 7 code
  159. end
  160. 1 private
  161. # Generate checkbox with custom icon/selectedIcon
  162. 1 def self.generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
  163. required_imports&.add(:icon_toggle_button)
  164. required_imports&.add(:icon)
  165. icon = json_data['icon'] || 'check_box_outline_blank'
  166. selected_icon = json_data['selectedIcon'] || 'check_box'
  167. # Resolve icon names to drawable resources
  168. icon_res = Helpers::ResourceResolver.process_drawable(icon, required_imports)
  169. selected_icon_res = Helpers::ResourceResolver.process_drawable(selected_icon, required_imports)
  170. code = indent("IconToggleButton(", depth)
  171. code += "\n" + indent("checked = #{checked},", depth + 1)
  172. # onCheckedChange handler
  173. binding_variable = nil
  174. state_attr_val = json_data['isOn'] || json_data['checked']
  175. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  176. binding_variable = $1
  177. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  178. binding_variable = $1
  179. end
  180. if json_data['onValueChange']
  181. # onValueChange (camelCase) -> binding format only (@{functionName})
  182. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  183. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  184. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 1)
  185. else
  186. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 1)
  187. end
  188. elsif binding_variable
  189. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 1)
  190. else
  191. code += "\n" + indent("onCheckedChange = { }", depth + 1)
  192. end
  193. # Build modifiers
  194. modifiers = []
  195. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  196. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  197. modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  198. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  199. code += "\n" + indent(") {", depth)
  200. # Icon content - switch based on checked state
  201. code += "\n" + indent("Icon(", depth + 1)
  202. code += "\n" + indent("painter = painterResource(if (#{checked}) #{selected_icon_res} else #{icon_res}),", depth + 2)
  203. code += "\n" + indent("contentDescription = null", depth + 2)
  204. # Icon tint color
  205. if json_data['fontColor']
  206. icon_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  207. code += ",\n" + indent("tint = #{icon_color}", depth + 2)
  208. end
  209. code += "\n" + indent(")", depth + 1)
  210. code += "\n" + indent("}", depth)
  211. code
  212. end
  213. 1 def self.indent(text, level)
  214. 31 return text if level == 0
  215. 17 spaces = ' ' * level
  216. 17 text.split("\n").map { |line|
  217. 17 line.empty? ? line : spaces + line
  218. }.join("\n")
  219. end
  220. end
  221. end
  222. end
  223. end

lib/compose/components/circleimage_component.rb

100.0% lines covered

53 relevant lines. 53 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class CircleImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # CircleImage can be local or network image
  10. 21 is_network = json_data['url'] || (json_data['source'] && json_data['source'].start_with?('http'))
  11. 21 if is_network
  12. 4 required_imports&.add(:async_image)
  13. 4 url = process_data_binding(json_data['url'] || json_data['source'] || json_data['src'] || '')
  14. 4 code = indent("AsyncImage(", depth)
  15. 4 code += "\n" + indent("model = #{url},", depth + 1)
  16. else
  17. # Local image
  18. 17 image_name = json_data['source'] || json_data['src'] || 'placeholder'
  19. # Remove file extension and convert to resource name
  20. 17 resource_name = image_name.gsub('.png', '').gsub('.jpg', '').gsub('-', '_').downcase
  21. 17 code = indent("Image(", depth)
  22. 17 code += "\n" + indent("painter = painterResource(id = R.drawable.#{resource_name}),", depth + 1)
  23. end
  24. 21 content_description = json_data['contentDescription'] || 'Profile Image'
  25. 21 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  26. # Content scale - typically Crop for circular images
  27. 21 required_imports&.add(:content_scale)
  28. 21 code += "\n" + indent("contentScale = ContentScale.Crop,", depth + 1)
  29. # Build modifiers for circular shape
  30. 21 modifiers = []
  31. # Size (use 'size' attribute or default to 48dp)
  32. 21 size = json_data['size'] || 48
  33. 21 modifiers << ".size(#{size}.dp)"
  34. # Circular clip
  35. 21 required_imports&.add(:shape)
  36. 21 modifiers << ".clip(CircleShape)"
  37. # Border for circle
  38. 21 if json_data['borderWidth'] && json_data['borderColor']
  39. 1 required_imports&.add(:border)
  40. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), CircleShape)"
  41. end
  42. # Background (in case image doesn't load)
  43. 21 if json_data['background']
  44. 1 required_imports&.add(:background)
  45. 1 modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['background']}', required_imports))"
  46. end
  47. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  50. # Error handling for network images
  51. 21 if is_network && json_data['errorImage']
  52. 1 code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  53. end
  54. 21 code += "\n" + indent(")", depth)
  55. 21 code
  56. end
  57. 1 private
  58. 1 def self.process_data_binding(text)
  59. 7 return quote(text) unless text.is_a?(String)
  60. 7 if text.match(/@\{([^}]+)\}/)
  61. 2 variable = $1
  62. 2 "data.#{variable}"
  63. else
  64. 5 quote(text)
  65. end
  66. end
  67. 1 def self.quote(text)
  68. 7 "\"#{text.gsub('"', '\\"')}\""
  69. end
  70. 1 def self.indent(text, level)
  71. 108 return text if level == 0
  72. 65 spaces = ' ' * level
  73. 65 text.split("\n").map { |line|
  74. 65 line.empty? ? line : spaces + line
  75. }.join("\n")
  76. end
  77. end
  78. end
  79. end
  80. end

lib/compose/components/collection_component.rb

99.53% lines covered

211 relevant lines. 210 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class CollectionComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 25 required_imports&.add(:lazy_grid)
  9. 25 required_imports&.add(:grid_item_span)
  10. # Check if sections are defined
  11. 25 sections = json_data['sections'] || []
  12. # Support both 'layout' and 'orientation' attributes for horizontal/vertical
  13. 25 layout = json_data['layout'] || json_data['orientation'] || 'vertical'
  14. 25 is_horizontal = layout == 'horizontal'
  15. # Legacy: Extract cellClasses, headerClasses, footerClasses (string arrays)
  16. 25 cell_classes = json_data['cellClasses'] || []
  17. 25 header_classes = json_data['headerClasses'] || []
  18. 25 footer_classes = json_data['footerClasses'] || []
  19. # Use the class names directly
  20. 25 cell_class_name = cell_classes.first if cell_classes.any?
  21. 25 header_class_name = header_classes.first if header_classes.any?
  22. 25 footer_class_name = footer_classes.first if footer_classes.any?
  23. # Calculate the grid columns based on sections or default
  24. 25 default_columns = json_data['columns'] || 1
  25. 25 if sections.any?
  26. # Collect all unique column counts from sections
  27. 24 section_columns = sections.map { |s| s['columns'] || default_columns }.uniq
  28. # If sections have different column counts, use LCM
  29. 11 if section_columns.size > 1
  30. 1 columns = calculate_lcm(section_columns)
  31. else
  32. 10 columns = section_columns.first
  33. end
  34. else
  35. 14 columns = default_columns
  36. end
  37. # Determine grid type based on layout
  38. 25 direction = is_horizontal ? 'horizontal' : 'vertical'
  39. 25 if direction == 'horizontal'
  40. 2 code = indent("LazyHorizontalGrid(", depth)
  41. 2 code += "\n" + indent("rows = GridCells.Fixed(#{columns}),", depth + 1)
  42. else
  43. 23 code = indent("LazyVerticalGrid(", depth)
  44. 23 code += "\n" + indent("columns = GridCells.Fixed(#{columns}),", depth + 1)
  45. end
  46. # Content padding
  47. 25 if json_data['contentPadding']
  48. 2 padding = json_data['contentPadding']
  49. 2 if padding.is_a?(Array) && padding.length == 4
  50. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
  51. 1 elsif padding.is_a?(Numeric)
  52. 1 code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
  53. end
  54. end
  55. # Item spacing
  56. # lineSpacing: vertical spacing between rows (minimumLineSpacing in iOS)
  57. # columnSpacing: horizontal spacing between columns (minimumInteritemSpacing in iOS)
  58. # itemSpacing/spacing: uniform spacing (fallback)
  59. 25 line_spacing = json_data['lineSpacing'] || json_data['itemSpacing'] || json_data['spacing']
  60. 25 column_spacing = json_data['columnSpacing'] || json_data['itemSpacing'] || json_data['spacing']
  61. 25 if line_spacing || column_spacing
  62. 2 required_imports&.add(:arrangement)
  63. 2 if line_spacing
  64. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{line_spacing}.dp),", depth + 1)
  65. end
  66. 2 if column_spacing
  67. 2 code += "\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{column_spacing}.dp),", depth + 1)
  68. end
  69. end
  70. # Build modifiers
  71. 25 modifiers = []
  72. 25 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  73. 25 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  74. 25 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  75. 25 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  76. 25 code += Helpers::ModifierBuilder.format(modifiers, depth)
  77. 25 code += "\n" + indent(") {", depth)
  78. # Check if sections are defined
  79. 25 if sections.any?
  80. # Generate section-based collection
  81. 11 code += generate_sections_content(json_data, sections, columns, depth, required_imports)
  82. 14 elsif cell_class_name
  83. # Check if items property is specified (e.g., "@{items}")
  84. 6 items_property = json_data['items']
  85. 6 if items_property && items_property.match(/@\{([^}]+)\}/)
  86. # Extract property name from @{propertyName}
  87. 4 property_name = $1
  88. # Items should be a Map<String, List<Any>> where key is cell class name
  89. # Get the items for this specific cell class
  90. 4 code += "\n" + indent("// Collection with data source: #{property_name}[\"#{cell_class_name}\"]", depth + 1)
  91. 4 code += "\n" + indent("val cellItems = data.#{property_name}[\"#{cell_class_name}\"] ?: emptyList()", depth + 1)
  92. 4 code += "\n" + indent("items(cellItems.size) { index ->", depth + 1)
  93. 4 code += "\n" + indent("val item = cellItems[index]", depth + 2)
  94. else
  95. # Default to empty list
  96. 2 code += "\n" + indent("// Collection with no data source", depth + 1)
  97. 2 code += "\n" + indent("items(0) { index ->", depth + 1)
  98. 2 code += "\n" + indent("// No items", depth + 2)
  99. end
  100. # Create cell view with data
  101. 6 code += "\n" + indent("when (val itemData = item) {", depth + 2)
  102. 6 code += "\n" + indent("is #{cell_class_name}Data -> {", depth + 3)
  103. 6 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  104. 6 code += "\n" + indent("data = itemData,", depth + 5)
  105. 6 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  106. 6 code += "\n" + indent("modifier = Modifier", depth + 5)
  107. # Cell-specific modifiers
  108. 6 if json_data['cellHeight']
  109. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  110. end
  111. # For grid layouts, ensure cells expand to fill width
  112. 6 if columns > 1
  113. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  114. end
  115. 6 code += "\n" + indent(")", depth + 4)
  116. 6 code += "\n" + indent("}", depth + 3)
  117. 6 code += "\n" + indent("is Map<*, *> -> {", depth + 3)
  118. 6 code += "\n" + indent("// Convert map to data class", depth + 4)
  119. 6 code += "\n" + indent("val data = #{cell_class_name}Data.fromMap(itemData as Map<String, Any>)", depth + 4)
  120. 6 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  121. 6 code += "\n" + indent("data = data,", depth + 5)
  122. 6 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  123. 6 code += "\n" + indent("modifier = Modifier", depth + 5)
  124. # Cell-specific modifiers
  125. 6 if json_data['cellHeight']
  126. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  127. end
  128. # For grid layouts, ensure cells expand to fill width
  129. 6 if columns > 1
  130. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  131. end
  132. 6 code += "\n" + indent(")", depth + 4)
  133. 6 code += "\n" + indent("}", depth + 3)
  134. 6 code += "\n" + indent("else -> {", depth + 3)
  135. 6 code += "\n" + indent("// Unsupported item type", depth + 4)
  136. 6 code += "\n" + indent("}", depth + 3)
  137. 6 code += "\n" + indent("}", depth + 2)
  138. 6 code += "\n" + indent("}", depth + 1)
  139. else
  140. # No cell class specified - show placeholder
  141. 8 code += "\n" + indent("// No cellClasses specified", depth + 1)
  142. 8 code += "\n" + indent("items(10) { index ->", depth + 1)
  143. 8 code += "\n" + indent("Card(", depth + 2)
  144. 8 code += "\n" + indent("modifier = Modifier", depth + 3)
  145. 8 code += "\n" + indent(" .padding(4.dp)", depth + 3)
  146. 8 code += "\n" + indent(" .fillMaxWidth()", depth + 3)
  147. 8 code += "\n" + indent(" .height(80.dp)", depth + 3)
  148. 8 code += "\n" + indent(") {", depth + 2)
  149. 8 code += "\n" + indent("Box(", depth + 3)
  150. 8 code += "\n" + indent("modifier = Modifier.fillMaxSize(),", depth + 4)
  151. 8 code += "\n" + indent("contentAlignment = Alignment.Center", depth + 4)
  152. 8 code += "\n" + indent(") {", depth + 3)
  153. 8 code += "\n" + indent("Text(\"Item ${index}\")", depth + 4)
  154. 8 code += "\n" + indent("}", depth + 3)
  155. 8 code += "\n" + indent("}", depth + 2)
  156. 8 code += "\n" + indent("}", depth + 1)
  157. end
  158. 25 code += "\n" + indent("}", depth)
  159. 25 code
  160. end
  161. 1 def self.generate_sections_content(json_data, sections, grid_columns, depth, required_imports)
  162. 11 code = ""
  163. 11 items_property = json_data['items']
  164. 11 default_columns = json_data['columns'] || 1
  165. # Check if we need GridItemSpan
  166. # Need it for headers/footers or when sections have different column counts
  167. 24 has_headers_or_footers = sections.any? { |s| s['header'] || s['footer'] }
  168. 24 section_columns_vary = sections.map { |s| s['columns'] || default_columns }.uniq.size > 1
  169. 23 needs_span = sections.any? { |s| s['columns'] && s['columns'] != grid_columns }
  170. 11 if has_headers_or_footers || section_columns_vary || needs_span
  171. 3 required_imports&.add(:grid_item_span)
  172. end
  173. 11 if items_property && items_property.match(/@\{([^}]+)\}/)
  174. 7 property_name = $1
  175. # Generate sections with GridItemSpan for different column counts
  176. 7 sections.each_with_index do |section, index|
  177. 7 cell_view_name = section['cell']
  178. 7 section_columns = section['columns'] || default_columns
  179. # Calculate the span for items in this section
  180. 7 item_span = grid_columns / section_columns
  181. 7 if cell_view_name
  182. # Add cell view imports
  183. 7 required_imports&.add("cell:#{cell_view_name}")
  184. # Add header import if exists
  185. 7 if section['header']
  186. 1 required_imports&.add("cell:#{section['header']}")
  187. end
  188. # Add footer import if exists
  189. 7 if section['footer']
  190. 1 required_imports&.add("cell:#{section['footer']}")
  191. end
  192. 7 code += "\n" + indent("// Section #{index + 1}: #{cell_view_name} (#{section_columns} columns)", depth + 1)
  193. 7 code += "\n" + indent("data.#{property_name}.sections.getOrNull(#{index})?.let { section ->", depth + 1)
  194. # Generate header if present
  195. 7 if section['header']
  196. 1 header_view_name = section['header']
  197. 1 code += "\n" + indent("// Section #{index + 1} Header: #{header_view_name}", depth + 2)
  198. 1 code += "\n" + indent("section.header?.let { headerData ->", depth + 2)
  199. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  200. 1 code += "\n" + indent("val data = #{header_view_name}Data.fromMap(headerData.data)", depth + 4)
  201. 1 code += "\n" + indent("#{header_view_name}View(", depth + 4)
  202. 1 code += "\n" + indent("data = data,", depth + 5)
  203. 1 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  204. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  205. 1 code += "\n" + indent(")", depth + 4)
  206. 1 code += "\n" + indent("}", depth + 3)
  207. 1 code += "\n" + indent("}", depth + 2)
  208. end
  209. # Generate cells
  210. 7 code += "\n" + indent("section.cells?.let { cellData ->", depth + 2)
  211. 7 if item_span > 1
  212. code += "\n" + indent("items(cellData.data.size, span = { GridItemSpan(#{item_span}) }) { cellIndex ->", depth + 3)
  213. else
  214. 7 code += "\n" + indent("items(cellData.data.size) { cellIndex ->", depth + 3)
  215. end
  216. 7 code += "\n" + indent("val item = cellData.data[cellIndex]", depth + 4)
  217. # Generate cell view instantiation
  218. 7 code += "\n" + indent("when (item) {", depth + 4)
  219. 7 code += "\n" + indent("is Map<*, *> -> {", depth + 5)
  220. 7 code += "\n" + indent("val data = #{cell_view_name}Data.fromMap(item as Map<String, Any>)", depth + 6)
  221. 7 code += "\n" + indent("#{cell_view_name}View(", depth + 6)
  222. 7 code += "\n" + indent("data = data,", depth + 7)
  223. 7 code += "\n" + indent("viewModel = viewModel(),", depth + 7)
  224. 7 code += "\n" + indent("modifier = Modifier", depth + 7)
  225. # Add modifiers
  226. 7 if json_data['cellWidth'] && json_data['layout'] == 'horizontal'
  227. 1 code += "\n" + indent(" .width(#{json_data['cellWidth']}.dp)", depth + 7)
  228. 6 elsif json_data['cellHeight']
  229. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 7)
  230. end
  231. 7 if section_columns > 1 && json_data['layout'] != 'horizontal'
  232. 3 code += "\n" + indent(" .fillMaxWidth()", depth + 7)
  233. end
  234. 7 code += "\n" + indent(")", depth + 6)
  235. 7 code += "\n" + indent("}", depth + 5)
  236. 7 code += "\n" + indent("else -> {", depth + 5)
  237. 7 code += "\n" + indent("// Unsupported item type", depth + 6)
  238. 7 code += "\n" + indent("}", depth + 5)
  239. 7 code += "\n" + indent("}", depth + 4)
  240. 7 code += "\n" + indent("}", depth + 3)
  241. 7 code += "\n" + indent("}", depth + 2)
  242. # Generate footer if present
  243. 7 if section['footer']
  244. 1 footer_view_name = section['footer']
  245. 1 code += "\n" + indent("// Section #{index + 1} Footer: #{footer_view_name}", depth + 2)
  246. 1 code += "\n" + indent("section.footer?.let { footerData ->", depth + 2)
  247. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  248. 1 code += "\n" + indent("val data = #{footer_view_name}Data.fromMap(footerData.data)", depth + 4)
  249. 1 code += "\n" + indent("#{footer_view_name}View(", depth + 4)
  250. 1 code += "\n" + indent("data = data,", depth + 5)
  251. 1 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  252. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  253. 1 code += "\n" + indent(")", depth + 4)
  254. 1 code += "\n" + indent("}", depth + 3)
  255. 1 code += "\n" + indent("}", depth + 2)
  256. end
  257. 7 code += "\n" + indent("}", depth + 1)
  258. end
  259. end
  260. else
  261. 4 code += "\n" + indent("// No items binding specified", depth + 1)
  262. end
  263. 11 code
  264. end
  265. 1 private
  266. 1 def self.calculate_lcm(numbers)
  267. 11 numbers.reduce(1) { |lcm, n| lcm.lcm(n) }
  268. end
  269. 1 def self.extract_view_name(class_name)
  270. 4 return nil unless class_name
  271. # Convert cell class name to Compose view name
  272. # Remove common suffixes and add appropriate naming
  273. 3 view_name = class_name
  274. # Remove common UIKit/Android suffixes
  275. 3 view_name = view_name.sub(/CollectionViewCell$/, '')
  276. 3 view_name = view_name.sub(/Cell$/, '')
  277. 3 view_name = view_name.sub(/cell$/, '')
  278. # Convert to proper case and add View suffix if needed
  279. 3 view_name = to_pascal_case(view_name)
  280. 3 view_name += 'View' unless view_name.end_with?('View')
  281. 3 view_name
  282. end
  283. 1 def self.to_pascal_case(str)
  284. 7 return str if str.nil? || str.empty?
  285. # Handle snake_case or kebab-case to PascalCase
  286. 5 parts = str.split(/[_-]/)
  287. 5 parts.map(&:capitalize).join
  288. end
  289. 1 def self.indent(text, level)
  290. 573 return text if level == 0
  291. 497 spaces = ' ' * level
  292. 497 text.split("\n").map { |line|
  293. 499 line.empty? ? line : spaces + line
  294. }.join("\n")
  295. end
  296. end
  297. end
  298. end
  299. end

lib/compose/components/constraintlayout_component.rb

91.03% lines covered

145 relevant lines. 132 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ConstraintLayoutComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 6 required_imports&.add(:constraint_layout)
  10. # Check if any child has relative positioning attributes
  11. 6 children = json_data['child'] || []
  12. 6 children = [children] unless children.is_a?(Array)
  13. 11 has_constraints = children.any? { |child| has_relative_positioning?(child) }
  14. 6 if has_constraints
  15. 4 generate_constraint_layout(json_data, children, depth, required_imports)
  16. else
  17. # Fall back to regular Box/Column/Row
  18. 2 Components::ContainerComponent.generate(json_data, depth, required_imports)
  19. end
  20. end
  21. 1 private
  22. 1 def self.has_relative_positioning?(component)
  23. 19 return false unless component.is_a?(Hash)
  24. 17 relative_attrs = [
  25. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  26. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  27. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  28. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight',
  29. 'centerHorizontal', 'centerVertical', 'centerInParent'
  30. ]
  31. 239 relative_attrs.any? { |attr| component[attr] }
  32. end
  33. 1 def self.has_positioning_constraints?(component)
  34. 19 return false unless component.is_a?(Hash)
  35. # These are constraints that use margins in linkTo()
  36. # For alignXxxView, margins should be applied as padding modifiers
  37. # For alignTop/Bottom/Left/Right to parent, margins are applied in linkTo()
  38. 18 positioning_attrs = [
  39. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  40. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  41. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  42. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight'
  43. ]
  44. # centerInParent, centerHorizontal, centerVertical don't use margins in linkTo()
  45. # so they should still apply margins as padding
  46. 245 positioning_attrs.any? { |attr| component[attr] }
  47. end
  48. 1 def self.should_apply_margins_as_padding?(component)
  49. 16 return false unless component.is_a?(Hash)
  50. # Don't apply margins as padding if they're already handled in linkTo()
  51. # All positioning constraints now handle margins in linkTo()
  52. 15 return !has_positioning_constraints?(component)
  53. end
  54. 1 def self.generate_constraint_layout(json_data, children, depth, required_imports)
  55. 4 code = indent("ConstraintLayout(", depth)
  56. # Build modifiers
  57. 4 modifiers = []
  58. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  59. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  60. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  61. 4 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  62. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  63. 4 code += "\n" + indent(") {", depth)
  64. # Create constraint references
  65. 4 constraint_refs = []
  66. 4 children.each_with_index do |child, index|
  67. 4 if child.is_a?(Hash) && (child['id'] || has_relative_positioning?(child))
  68. 4 ref_name = child['id'] || "view_#{index}"
  69. 4 code += "\n" + indent("val #{ref_name} = createRef()", depth + 1)
  70. 4 constraint_refs << ref_name
  71. end
  72. end
  73. 4 code += "\n" if constraint_refs.any?
  74. # Generate children with constraints
  75. 4 children.each_with_index do |child, index|
  76. 4 if child.is_a?(Hash)
  77. 4 ref_name = child['id'] || "view_#{index}"
  78. # Generate the child component
  79. 4 child_code = generate_child_with_constraints(child, ref_name, depth + 1, required_imports)
  80. 4 code += "\n" + child_code unless child_code.empty?
  81. end
  82. end
  83. 4 code += "\n" + indent("}", depth)
  84. 4 code
  85. end
  86. 1 def self.generate_child_with_constraints(child_data, ref_name, depth, required_imports)
  87. # Get the component type
  88. 4 component_type = child_data['type'] || 'View'
  89. # Generate the component code based on type
  90. 4 component_code = case component_type
  91. when 'Text', 'Label'
  92. 3 generate_text_component(child_data, depth, required_imports)
  93. when 'Button'
  94. 1 generate_button_component(child_data, depth, required_imports)
  95. when 'Image'
  96. generate_image_component(child_data, depth, required_imports)
  97. else
  98. generate_box_component(child_data, depth, required_imports)
  99. end
  100. # Add constrainAs modifier
  101. 4 constraints = Helpers::ModifierBuilder.build_relative_positioning(child_data)
  102. # Always add constrainAs for all children in ConstraintLayout
  103. # Insert constrainAs modifier
  104. 4 constraint_content = if constraints.any?
  105. 12 constraints.map { |c| indent(c, depth + 2) }.join("\n")
  106. else
  107. "" # Empty constraint block
  108. end
  109. # Find where to insert the constrainAs modifier
  110. 4 if component_code.include?("modifier = Modifier")
  111. # Replace existing modifier with constrainAs
  112. component_code.sub!(/modifier = Modifier(.*?)(?=,\n|\n)/m) do |match|
  113. existing_modifiers = $1
  114. "modifier = Modifier.constrainAs(#{ref_name}) {\n#{constraint_content}\n" + indent("}", depth + 1) + existing_modifiers
  115. end
  116. else
  117. # Add new modifier after the opening parenthesis
  118. 4 insert_pos = component_code.index("(") + 1
  119. 4 modifier_code = "\n" + indent("modifier = Modifier.constrainAs(#{ref_name}) {", depth + 1)
  120. 4 if constraint_content.length > 0
  121. 4 modifier_code += "\n#{constraint_content}"
  122. end
  123. 4 modifier_code += "\n" + indent("}", depth + 1) + ","
  124. 4 component_code.insert(insert_pos, modifier_code)
  125. end
  126. 4 component_code
  127. end
  128. 1 def self.generate_text_component(data, depth, required_imports)
  129. 13 text = data['text'] || ''
  130. # Check for data binding
  131. 13 if text.start_with?('@{')
  132. 1 variable_name = text[2..-2]
  133. 1 escaped_text = "\"${data.#{variable_name}}\""
  134. else
  135. 12 escaped_text = quote(text)
  136. end
  137. 13 code = indent("Text(", depth)
  138. # Add modifier with constraints
  139. # In ConstraintLayout:
  140. # - If element has relative positioning constraints, margins are handled ONLY in linkTo()
  141. # - If element has no constraints (just centerInParent etc), margins are applied as padding
  142. 13 modifiers = []
  143. # Apply margins BEFORE size so they act as outer spacing
  144. # This ensures the size is the actual content size, not reduced by margins
  145. 13 if should_apply_margins_as_padding?(data)
  146. 11 modifiers.concat(Helpers::ModifierBuilder.build_margins(data))
  147. end
  148. 13 modifiers.concat(Helpers::ModifierBuilder.build_size(data))
  149. 13 modifiers.concat(Helpers::ModifierBuilder.build_background(data, required_imports))
  150. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(data))
  151. 13 if modifiers.any?
  152. code += "\n" + indent("modifier = Modifier", depth + 1)
  153. modifiers.each do |mod|
  154. code += "\n" + indent(" #{mod}", depth + 1)
  155. end
  156. code += ","
  157. end
  158. 13 code += "\n" + indent("text = #{escaped_text}", depth + 1)
  159. 13 if data['fontSize']
  160. 1 code += ",\n" + indent("fontSize = #{data['fontSize']}.sp", depth + 1)
  161. end
  162. 13 if data['fontColor'] || data['color']
  163. 2 color = data['fontColor'] || data['color']
  164. 2 color_resolved = Helpers::ResourceResolver.process_color(color, required_imports)
  165. 2 code += ",\n" + indent("color = #{color_resolved}", depth + 1)
  166. end
  167. 13 if data['font'] == 'bold' || data['fontWeight'] == 'bold'
  168. 2 required_imports&.add(:font_weight)
  169. 2 code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 1)
  170. end
  171. 13 if data['textAlign']
  172. 3 required_imports&.add(:text_align)
  173. 3 align = case data['textAlign']
  174. 1 when 'center' then 'TextAlign.Center'
  175. 1 when 'left' then 'TextAlign.Left'
  176. 1 when 'right' then 'TextAlign.Right'
  177. else 'TextAlign.Start'
  178. end
  179. 3 code += ",\n" + indent("textAlign = #{align}", depth + 1)
  180. end
  181. 13 code += "\n" + indent(")", depth)
  182. 13 code
  183. end
  184. 1 def self.generate_button_component(data, depth, required_imports)
  185. 5 text = data['text'] || 'Button'
  186. # Properly escape text
  187. 5 escaped_text = quote(text)
  188. 5 code = indent("Button(", depth)
  189. # onclick (lowercase) -> selector format only
  190. # onClick (camelCase) -> binding format only
  191. 5 if data['onclick']
  192. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onclick'], is_camel_case: false)
  193. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  194. 4 elsif data['onClick']
  195. handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onClick'], is_camel_case: true)
  196. code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  197. else
  198. 4 code += "\n" + indent("onClick = { }", depth + 1)
  199. end
  200. 5 code += "\n" + indent(") {", depth)
  201. 5 code += "\n" + indent("Text(#{escaped_text})", depth + 1)
  202. 5 code += "\n" + indent("}", depth)
  203. 5 code
  204. end
  205. 1 def self.generate_image_component(data, depth, required_imports)
  206. 4 source = data['src'] || data['source'] || 'placeholder'
  207. 4 code = indent("Image(", depth)
  208. 4 code += "\n" + indent("painter = painterResource(R.drawable.#{source.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  209. 4 code += "\n" + indent("contentDescription = \"Image\"", depth + 1)
  210. 4 code += "\n" + indent(")", depth)
  211. 4 code
  212. end
  213. 1 def self.generate_box_component(data, depth, required_imports)
  214. 1 code = indent("Box(", depth)
  215. 1 code += "\n" + indent(") {", depth)
  216. 1 code += "\n" + indent("// Content", depth + 1)
  217. 1 code += "\n" + indent("}", depth)
  218. 1 code
  219. end
  220. 1 def self.quote(text)
  221. # Escape special characters properly
  222. 20 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  223. .gsub('"', '\\"') # Escape quotes
  224. .gsub("\n", '\\n') # Escape newlines
  225. .gsub("\r", '\\r') # Escape carriage returns
  226. .gsub("\t", '\\t') # Escape tabs
  227. 20 "\"#{escaped}\""
  228. end
  229. 1 def self.indent(text, level)
  230. 127 return text if level == 0
  231. 71 spaces = ' ' * level
  232. 71 text.split("\n").map { |line|
  233. 73 line.empty? ? line : spaces + line
  234. }.join("\n")
  235. end
  236. end
  237. end
  238. end
  239. end

lib/compose/components/container_component.rb

84.62% lines covered

91 relevant lines. 77 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative 'constraintlayout_component'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ContainerComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 container_type = json_data['type'] || 'View'
  10. 22 orientation = json_data['orientation']
  11. # Check if any child has relative positioning
  12. 22 children = json_data['child'] || []
  13. 22 children = [children] unless children.is_a?(Array)
  14. 22 if has_relative_positioning?(children)
  15. # Use ConstraintLayout for relative positioning
  16. return ConstraintLayoutComponent.generate(json_data, depth, required_imports)
  17. end
  18. # Determine layout type
  19. 22 layout = determine_layout(container_type, orientation)
  20. 22 code = indent("#{layout}(", depth)
  21. # Build modifiers (correct order for Compose)
  22. 22 modifiers = []
  23. # Add weight modifier if in Row or Column
  24. 22 if parent_type == 'Row' || parent_type == 'Column'
  25. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  26. end
  27. # 1. Size first (total size including padding)
  28. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  29. # 2. Margins (outer spacing)
  30. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  31. # 3. Background (before padding so padding creates space inside)
  32. 22 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  33. # 4. Padding (inner spacing) - applied last
  34. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  35. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  36. # Add gravity settings
  37. 22 if json_data['gravity']
  38. 6 code += add_gravity_settings(layout, json_data['gravity'], depth)
  39. end
  40. # Add direction settings
  41. # Note: reverseLayout is only supported by LazyColumn/LazyRow, not Column/Row
  42. # For regular Row/Column, we need to manually reverse the children order
  43. 22 if json_data['direction'] && (layout == 'Column' || layout == 'Row')
  44. # Direction handling will be done by reversing children order
  45. # No reverseLayout parameter for regular Row/Column
  46. end
  47. # Add spacing for Column/Row
  48. 22 if json_data['spacing'] && (layout == 'Column' || layout == 'Row')
  49. 2 required_imports&.add(:arrangement)
  50. 2 code += ",\n" + indent("verticalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Column'
  51. 2 code += ",\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Row'
  52. end
  53. # Add distribution for Column/Row
  54. 22 if json_data['distribution'] && (layout == 'Column' || layout == 'Row')
  55. 3 required_imports&.add(:arrangement)
  56. 3 arrangement = case json_data['distribution']
  57. when 'fillEqually'
  58. 1 'Arrangement.SpaceEvenly'
  59. when 'fill'
  60. 1 'Arrangement.SpaceBetween'
  61. when 'equalSpacing'
  62. 1 'Arrangement.SpaceAround'
  63. when 'equalCentering'
  64. 'Arrangement.SpaceEvenly'
  65. else
  66. nil
  67. end
  68. 3 if arrangement
  69. 3 code += ",\n" + indent("verticalArrangement = #{arrangement}", depth + 1) if layout == 'Column'
  70. 3 code += ",\n" + indent("horizontalArrangement = #{arrangement}", depth + 1) if layout == 'Row'
  71. end
  72. end
  73. 22 code += "\n" + indent(") {", depth)
  74. # Process children
  75. 22 children = json_data['child'] || []
  76. 22 children = [children] unless children.is_a?(Array)
  77. # Reverse children order if direction requires it
  78. 22 if json_data['direction']
  79. 2 case json_data['direction']
  80. when 'bottomToTop'
  81. 1 children = children.reverse if layout == 'Column'
  82. when 'rightToLeft'
  83. 1 children = children.reverse if layout == 'Row'
  84. end
  85. end
  86. # Return structure for parent to process children
  87. 22 { code: code, children: children, closing: "\n" + indent("}", depth), layout_type: layout, json_data: json_data }
  88. end
  89. 1 private
  90. 1 def self.has_relative_positioning?(children)
  91. 25 relative_attrs = [
  92. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  93. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  94. 'alignCenterVerticalView', 'alignCenterHorizontalView'
  95. ]
  96. 25 children.any? do |child|
  97. 12 next false unless child.is_a?(Hash)
  98. 101 relative_attrs.any? { |attr| child[attr] }
  99. end
  100. end
  101. 1 def self.determine_layout(container_type, orientation)
  102. # SwiftJsonUI only has 'View' type, not VStack/HStack/ZStack
  103. # Layout is determined by orientation attribute:
  104. # - orientation: "vertical" → Column (VStack)
  105. # - orientation: "horizontal" → Row (HStack)
  106. # - no orientation → Box (ZStack)
  107. 26 if container_type == 'View'
  108. 23 if orientation == 'vertical'
  109. 12 'Column'
  110. 11 elsif orientation == 'horizontal'
  111. 7 'Row'
  112. else
  113. 4 'Box'
  114. end
  115. else
  116. # For other types (shouldn't happen with proper View type)
  117. 3 'Box'
  118. end
  119. end
  120. 1 def self.add_gravity_settings(layout, gravity, depth)
  121. 6 code = ""
  122. 6 if layout == 'Column'
  123. 4 case gravity
  124. when 'top'
  125. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Top", depth + 1)
  126. when 'bottom'
  127. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Bottom", depth + 1)
  128. when 'centerVertical'
  129. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
  130. when 'left'
  131. code += ",\n" + indent("horizontalAlignment = Alignment.Start", depth + 1)
  132. when 'right'
  133. code += ",\n" + indent("horizontalAlignment = Alignment.End", depth + 1)
  134. when 'centerHorizontal'
  135. 1 code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
  136. when 'center'
  137. # center applies both vertical arrangement and horizontal alignment
  138. code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
  139. code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
  140. end
  141. 2 elsif layout == 'Row'
  142. 2 case gravity
  143. when 'left'
  144. 1 code += ",\n" + indent("horizontalArrangement = Arrangement.Start", depth + 1)
  145. when 'right'
  146. code += ",\n" + indent("horizontalArrangement = Arrangement.End", depth + 1)
  147. when 'centerHorizontal'
  148. code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
  149. when 'top'
  150. code += ",\n" + indent("verticalAlignment = Alignment.Top", depth + 1)
  151. when 'bottom'
  152. code += ",\n" + indent("verticalAlignment = Alignment.Bottom", depth + 1)
  153. when 'centerVertical'
  154. 1 code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  155. when 'center'
  156. # center applies both horizontal arrangement and vertical alignment
  157. code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
  158. code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  159. end
  160. end
  161. 6 code
  162. end
  163. 1 def self.indent(text, level)
  164. 77 return text if level == 0
  165. 11 spaces = ' ' * level
  166. 11 text.split("\n").map { |line|
  167. 11 line.empty? ? line : spaces + line
  168. }.join("\n")
  169. end
  170. end
  171. end
  172. end
  173. end

lib/compose/components/gradientview_component.rb

72.73% lines covered

44 relevant lines. 32 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class GradientviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # GradientView maps to a Box with gradient background
  10. 4 code = indent("Box(", depth)
  11. # Build modifiers
  12. 4 modifiers = []
  13. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  14. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  15. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  16. # Add gradient background
  17. # Support both 'colors' and 'items' for color list
  18. 4 colors = json_data['colors'] || json_data['items'] || ['#000000', '#FFFFFF']
  19. # Determine gradient direction from orientation or start/end points
  20. 4 gradient_type = if json_data['orientation']
  21. case json_data['orientation']
  22. when 'horizontal'
  23. 'horizontalGradient'
  24. when 'vertical'
  25. 'verticalGradient'
  26. when 'diagonal'
  27. 'linearGradient'
  28. else
  29. 'verticalGradient'
  30. end
  31. else
  32. 4 start_point = json_data['startPoint'] || 'top'
  33. 4 end_point = json_data['endPoint'] || 'bottom'
  34. 4 case [start_point, end_point]
  35. when ['top', 'bottom'], ['bottom', 'top']
  36. 4 'verticalGradient'
  37. when ['left', 'right'], ['leading', 'trailing'], ['right', 'left'], ['trailing', 'leading']
  38. 'horizontalGradient'
  39. else
  40. 'linearGradient'
  41. end
  42. end
  43. # Build color list - process colors at generation time, not runtime
  44. 4 color_list = colors.map { |color|
  45. 8 Helpers::ResourceResolver.process_color(color, required_imports)
  46. }.join(", ")
  47. # Add gradient modifier
  48. 4 required_imports&.add(:gradient)
  49. 4 modifiers << ".background(Brush.#{gradient_type}(listOf(#{color_list})))"
  50. # Add corner radius if specified
  51. 4 if json_data['cornerRadius']
  52. required_imports&.add(:shape)
  53. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  54. end
  55. 4 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  56. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  57. 4 code += "\n" + indent(") {", depth)
  58. # Process children
  59. 4 children = json_data['child'] || []
  60. 4 children = [children] unless children.is_a?(Array)
  61. # Return structure for parent to process children
  62. 4 { code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
  63. end
  64. 1 private
  65. 1 def self.indent(text, level)
  66. 12 return text if level == 0
  67. spaces = ' ' * level
  68. text.split("\n").map { |line|
  69. line.empty? ? line : spaces + line
  70. }.join("\n")
  71. end
  72. end
  73. end
  74. end
  75. end

lib/compose/components/image_component.rb

87.23% lines covered

47 relevant lines. 41 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ImageComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # 'src' is the official attribute for images per wiki
  9. 10 raw_src = json_data['src'] || 'placeholder'
  10. # Add required imports
  11. 10 required_imports&.add(:image)
  12. 10 code = indent("Image(", depth)
  13. # Check if src is a binding expression
  14. 10 if Helpers::ModifierBuilder.is_binding?(raw_src)
  15. # @{mapTabIcon} -> viewModel.data.mapTabIcon (expects Painter type in Data)
  16. property_name = Helpers::ModifierBuilder.extract_binding_property(raw_src)
  17. camel_case_name = to_camel_case(property_name)
  18. # Binding case doesn't need painterResource import since Data provides Painter directly
  19. code += "\n" + indent("painter = viewModel.data.#{camel_case_name},", depth + 1)
  20. else
  21. # Static resource name needs painterResource
  22. 10 required_imports&.add(:painter_resource)
  23. 10 required_imports&.add(:r_class)
  24. 10 code += "\n" + indent("painter = painterResource(id = R.drawable.#{raw_src}),", depth + 1)
  25. end
  26. # Content description for accessibility
  27. 10 content_desc = json_data['contentDescription'] || ''
  28. 10 code += "\n" + indent("contentDescription = #{quote(content_desc)},", depth + 1)
  29. # Build modifiers
  30. 10 modifiers = []
  31. # Size handling
  32. 10 if json_data['width'] && json_data['height']
  33. 1 modifiers << ".size(#{json_data['width']}.dp, #{json_data['height']}.dp)"
  34. 9 elsif json_data['size']
  35. 1 modifiers << ".size(#{json_data['size']}.dp)"
  36. else
  37. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. end
  39. 10 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  40. 10 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  41. 10 code += Helpers::ModifierBuilder.format(modifiers, depth)
  42. # Content mode
  43. 10 if json_data['contentMode']
  44. 3 required_imports&.add(:content_scale)
  45. 3 case json_data['contentMode'].downcase
  46. when 'aspectfill'
  47. 1 code += ",\n" + indent("contentScale = ContentScale.Crop", depth + 1)
  48. when 'aspectfit'
  49. 1 code += ",\n" + indent("contentScale = ContentScale.Fit", depth + 1)
  50. when 'center'
  51. 1 code += ",\n" + indent("contentScale = ContentScale.None", depth + 1)
  52. end
  53. end
  54. 10 code += "\n" + indent(")", depth)
  55. 10 code
  56. end
  57. 1 private
  58. 1 def self.to_camel_case(snake_case_string)
  59. return snake_case_string unless snake_case_string.include?('_')
  60. parts = snake_case_string.split('_')
  61. parts[0] + parts[1..-1].map(&:capitalize).join
  62. end
  63. 1 def self.quote(text)
  64. 10 "\"#{text.gsub('"', '\\"')}\""
  65. end
  66. 1 def self.indent(text, level)
  67. 43 return text if level == 0
  68. 23 spaces = ' ' * level
  69. 23 text.split("\n").map { |line|
  70. 23 line.empty? ? line : spaces + line
  71. }.join("\n")
  72. end
  73. end
  74. end
  75. end
  76. end

lib/compose/components/indicator_component.rb

64.29% lines covered

56 relevant lines. 36 lines covered and 20 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class IndicatorComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Indicator can be circular or linear based on style
  10. 3 style = json_data['style'] || 'medium'
  11. 3 is_animating = json_data['animating']
  12. # Check if animating is controlled by data binding
  13. 3 show_condition = if is_animating && is_animating.is_a?(String) && is_animating.match(/@\{([^}]+)\}/)
  14. variable = $1
  15. "data.#{variable}"
  16. 3 elsif is_animating == false
  17. 'false'
  18. else
  19. 3 'true'
  20. end
  21. # Wrap in if condition if controlled by animating attribute
  22. 3 if is_animating != nil
  23. code = indent("if (#{show_condition}) {", depth)
  24. actual_depth = depth + 1
  25. else
  26. 3 code = ""
  27. 3 actual_depth = depth
  28. end
  29. # Determine indicator type based on style
  30. 3 if style == 'linear'
  31. code += "\n" if is_animating != nil
  32. code += indent("LinearProgressIndicator(", actual_depth)
  33. else
  34. 3 code += "\n" if is_animating != nil
  35. 3 code += indent("CircularProgressIndicator(", actual_depth)
  36. end
  37. # Build modifiers
  38. 3 modifiers = []
  39. # Size based on style
  40. 3 if style == 'large'
  41. modifiers << ".size(48.dp)"
  42. 3 elsif style == 'small'
  43. modifiers << ".size(16.dp)"
  44. 3 elsif json_data['size']
  45. modifiers << ".size(#{json_data['size']}.dp)"
  46. end
  47. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  50. # Add weight modifier if in Row or Column
  51. 3 if parent_type == 'Row' || parent_type == 'Column'
  52. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  53. end
  54. 3 code += Helpers::ModifierBuilder.format(modifiers, actual_depth) if modifiers.any?
  55. # Color
  56. 3 if json_data['color']
  57. color_resolved = Helpers::ResourceResolver.process_color(json_data['color'], required_imports)
  58. code += ",\n" + indent("color = #{color_resolved}", actual_depth + 1)
  59. end
  60. # Track color for linear progress
  61. 3 if style == 'linear' && json_data['trackColor']
  62. trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackColor'], required_imports)
  63. code += ",\n" + indent("trackColor = #{trackcolor_resolved}", actual_depth + 1)
  64. end
  65. # Stroke width for circular progress
  66. 3 if style != 'linear' && json_data['strokeWidth']
  67. code += ",\n" + indent("strokeWidth = #{json_data['strokeWidth']}.dp", actual_depth + 1)
  68. end
  69. 3 code += "\n" + indent(")", actual_depth)
  70. # Close if condition
  71. 3 if is_animating != nil
  72. code += "\n" + indent("}", depth)
  73. end
  74. 3 code
  75. end
  76. 1 private
  77. 1 def self.indent(text, level)
  78. 6 return text if level == 0
  79. spaces = ' ' * level
  80. text.split("\n").map { |line|
  81. line.empty? ? line : spaces + line
  82. }.join("\n")
  83. end
  84. end
  85. end
  86. end
  87. end

lib/compose/components/networkimage_component.rb

74.14% lines covered

58 relevant lines. 43 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class NetworkImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 5 required_imports&.add(:async_image)
  10. # NetworkImage uses 'source' or 'url' for image URL
  11. 5 url = process_data_binding(json_data['source'] || json_data['url'] || json_data['src'] || '')
  12. # Support both 'hint' (primary) and 'placeholder' (alias)
  13. 5 placeholder = json_data['hint'] || json_data['placeholder']
  14. 5 content_description = json_data['contentDescription'] || 'Image'
  15. 5 code = indent("AsyncImage(", depth)
  16. 5 code += "\n" + indent("model = #{url},", depth + 1)
  17. 5 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  18. # Content scale
  19. 5 if json_data['contentMode']
  20. required_imports&.add(:content_scale)
  21. scale = case json_data['contentMode']
  22. when 'aspectFit'
  23. 'ContentScale.Fit'
  24. when 'aspectFill'
  25. 'ContentScale.Crop'
  26. when 'fill', 'scaleToFill'
  27. 'ContentScale.FillBounds'
  28. when 'center'
  29. 'ContentScale.None'
  30. else
  31. 'ContentScale.Fit'
  32. end
  33. code += "\n" + indent("contentScale = #{scale},", depth + 1)
  34. end
  35. # Placeholder
  36. 5 if placeholder
  37. 1 code += "\n" + indent("placeholder = painterResource(R.drawable.#{placeholder.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  38. end
  39. # Build modifiers
  40. 5 modifiers = []
  41. # Handle size
  42. 5 if json_data['size']
  43. # size is a single value for both width and height
  44. modifiers << ".size(#{json_data['size']}.dp)"
  45. else
  46. 5 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  47. end
  48. # Corner radius for rounded images
  49. 5 if json_data['cornerRadius']
  50. required_imports&.add(:shape)
  51. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  52. end
  53. # Border
  54. 5 if json_data['borderWidth'] && json_data['borderColor']
  55. required_imports&.add(:border)
  56. shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  57. modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), #{shape})"
  58. end
  59. 5 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  60. 5 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  61. 5 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  62. 5 code += Helpers::ModifierBuilder.format(modifiers, depth)
  63. # Error handling
  64. 5 if json_data['errorImage']
  65. code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  66. end
  67. 5 code += "\n" + indent(")", depth)
  68. 5 code
  69. end
  70. 1 private
  71. 1 def self.process_data_binding(text)
  72. 5 return quote(text) unless text.is_a?(String)
  73. 5 if text.match(/@\{([^}]+)\}/)
  74. 1 variable = $1
  75. 1 "data.#{variable}"
  76. else
  77. 4 quote(text)
  78. end
  79. end
  80. 1 def self.quote(text)
  81. 4 "\"#{text.gsub('"', '\\"')}\""
  82. end
  83. 1 def self.indent(text, level)
  84. 21 return text if level == 0
  85. 11 spaces = ' ' * level
  86. 11 text.split("\n").map { |line|
  87. 11 line.empty? ? line : spaces + line
  88. }.join("\n")
  89. end
  90. end
  91. end
  92. end
  93. end

lib/compose/components/progress_component.rb

97.87% lines covered

47 relevant lines. 46 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ProgressComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Progress can have a value (determinate) or be indeterminate
  10. 15 has_value = json_data['value'] || json_data['bind']
  11. 15 if has_value
  12. # Determinate progress (LinearProgressIndicator)
  13. 3 value = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}.toFloat()"
  16. 2 elsif json_data['value'] && json_data['value'].match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}.toFloat()"
  19. 1 elsif json_data['value']
  20. 1 "#{json_data['value']}f"
  21. else
  22. '0f'
  23. end
  24. 3 code = indent("LinearProgressIndicator(", depth)
  25. 3 code += "\n" + indent("progress = { #{value} },", depth + 1)
  26. else
  27. # Indeterminate progress
  28. 12 style = json_data['style'] || 'linear'
  29. 12 if style == 'circular' || style == 'large'
  30. 2 code = indent("CircularProgressIndicator(", depth)
  31. else
  32. 10 code = indent("LinearProgressIndicator(", depth)
  33. end
  34. end
  35. # Build modifiers
  36. 15 modifiers = []
  37. 15 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 15 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 15 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 15 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  41. # Progress colors
  42. 15 if json_data['progressTintColor'] || json_data['trackTintColor']
  43. 3 colors_params = []
  44. 3 if json_data['progressTintColor']
  45. 2 color_resolved = Helpers::ResourceResolver.process_color(json_data['progressTintColor'], required_imports)
  46. 2 colors_params << "color = #{color_resolved}"
  47. end
  48. 3 if json_data['trackTintColor']
  49. 2 trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackTintColor'], required_imports)
  50. 2 colors_params << "trackColor = #{trackcolor_resolved}"
  51. end
  52. 3 if colors_params.any?
  53. 7 code += ",\n" + colors_params.map { |param| indent(param, depth + 1) }.join(",\n")
  54. end
  55. end
  56. 15 code += "\n" + indent(")", depth)
  57. 15 code
  58. end
  59. 1 private
  60. 1 def self.indent(text, level)
  61. 41 return text if level == 0
  62. 10 spaces = ' ' * level
  63. 10 text.split("\n").map { |line|
  64. 13 line.empty? ? line : spaces + line
  65. }.join("\n")
  66. end
  67. end
  68. end
  69. end
  70. end

lib/compose/components/radio_component.rb

92.49% lines covered

213 relevant lines. 197 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class RadioComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Handle Radio group with items FIRST (higher priority)
  10. 15 if json_data['items']
  11. 3 return generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  12. end
  13. # Handle individual Radio item (not a group)
  14. 12 if json_data['group'] || json_data['text']
  15. 6 return generate_radio_item(json_data, depth, required_imports, parent_type)
  16. end
  17. # Radio uses 'bind' for selected value
  18. 6 selected = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 4 variable = $1
  20. 4 "data.#{variable}"
  21. else
  22. 2 '""'
  23. end
  24. 6 code = indent("Column(", depth)
  25. # Build modifiers
  26. 6 modifiers = []
  27. 6 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  28. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  29. 6 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  30. 6 code += "\n" + indent(") {", depth)
  31. # Radio options
  32. 6 if json_data['options']
  33. 5 if json_data['options'].is_a?(Array)
  34. 4 json_data['options'].each do |option|
  35. 9 option_value = option.is_a?(Hash) ? option['value'] : option
  36. 9 option_label = option.is_a?(Hash) ? option['label'] : option
  37. 9 code += "\n" + indent("Row(", depth + 1)
  38. 9 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 2)
  39. 9 code += "\n" + indent("modifier = Modifier", depth + 2)
  40. 9 code += "\n" + indent(" .fillMaxWidth()", depth + 2)
  41. 9 code += "\n" + indent(" .clickable {", depth + 2)
  42. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  43. 7 variable = $1
  44. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 2)
  45. 2 elsif json_data['onValueChange']
  46. # onValueChange (camelCase) -> binding format only (@{functionName})
  47. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  48. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  49. 2 code += "\n" + indent(" viewModel.#{method_name}(\"#{option_value}\")", depth + 2)
  50. else
  51. code += "\n" + indent(" // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 2)
  52. end
  53. end
  54. 9 code += "\n" + indent(" }", depth + 2)
  55. 9 code += "\n" + indent(") {", depth + 1)
  56. # RadioButton
  57. 9 code += "\n" + indent("RadioButton(", depth + 2)
  58. 9 code += "\n" + indent("selected = (#{selected} == \"#{option_value}\"),", depth + 3)
  59. 9 code += "\n" + indent("onClick = {", depth + 3)
  60. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  61. 7 variable = $1
  62. 7 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 4)
  63. 2 elsif json_data['onValueChange']
  64. # onValueChange (camelCase) -> binding format only (@{functionName})
  65. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  66. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  67. 2 code += "\n" + indent("viewModel.#{method_name}(\"#{option_value}\")", depth + 4)
  68. else
  69. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
  70. end
  71. end
  72. 9 code += "\n" + indent("}", depth + 3)
  73. # RadioButton colors
  74. 9 if json_data['selectedColor'] || json_data['unselectedColor']
  75. 2 required_imports&.add(:radio_colors)
  76. 2 colors_params = []
  77. 2 if json_data['selectedColor']
  78. 2 selectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['selectedColor'], required_imports)
  79. 2 colors_params << "selectedColor = #{selectedcolor_resolved}"
  80. end
  81. 2 if json_data['unselectedColor']
  82. 2 unselectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['unselectedColor'], required_imports)
  83. 2 colors_params << "unselectedColor = #{unselectedcolor_resolved}"
  84. end
  85. 2 if colors_params.any?
  86. 2 code += ",\n" + indent("colors = RadioButtonDefaults.colors(", depth + 3)
  87. 6 code += "\n" + colors_params.map { |param| indent(param, depth + 4) }.join(",\n")
  88. 2 code += "\n" + indent(")", depth + 3)
  89. end
  90. end
  91. 9 code += "\n" + indent(")", depth + 2)
  92. # Label text
  93. 9 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 2)
  94. 9 code += "\n" + indent("Text(\"#{option_label}\")", depth + 2)
  95. 9 code += "\n" + indent("}", depth + 1)
  96. end
  97. 1 elsif json_data['options'].is_a?(String) && json_data['options'].match(/@\{([^}]+)\}/)
  98. # Dynamic options from data binding
  99. 1 options_var = $1
  100. 1 code += "\n" + indent("data.#{options_var}.forEach { option ->", depth + 1)
  101. 1 code += "\n" + indent("Row(", depth + 2)
  102. 1 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 3)
  103. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth().clickable {", depth + 3)
  104. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  105. 1 variable = $1
  106. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 4)
  107. end
  108. 1 code += "\n" + indent("}", depth + 3)
  109. 1 code += "\n" + indent(") {", depth + 2)
  110. 1 code += "\n" + indent("RadioButton(", depth + 3)
  111. 1 code += "\n" + indent("selected = (#{selected} == option),", depth + 4)
  112. 1 code += "\n" + indent("onClick = {", depth + 4)
  113. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  114. 1 variable = $1
  115. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 5)
  116. end
  117. 1 code += "\n" + indent("}", depth + 4)
  118. 1 code += "\n" + indent(")", depth + 3)
  119. 1 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 3)
  120. 1 code += "\n" + indent("Text(option)", depth + 3)
  121. 1 code += "\n" + indent("}", depth + 2)
  122. 1 code += "\n" + indent("}", depth + 1)
  123. end
  124. end
  125. 6 code += "\n" + indent("}", depth)
  126. 6 code
  127. end
  128. 1 private
  129. 1 def self.generate_radio_item(json_data, depth, required_imports, parent_type)
  130. 6 group = json_data['group'] || 'default'
  131. 6 id = json_data['id'] || "radio_#{rand(1000)}"
  132. 6 text = json_data['text'] || ''
  133. # Get the selected state from binding
  134. 6 selected_var = "selectedRadiogroup" # Default variable name
  135. 6 if group.downcase != 'default'
  136. # Use group name as part of the variable
  137. 1 selected_var = "selected#{group.capitalize}"
  138. end
  139. 6 code = indent("Row(", depth)
  140. 6 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  141. # Build modifiers
  142. 6 modifiers = []
  143. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  144. 6 if modifiers.any?
  145. code += "\n" + indent(" modifier = Modifier", depth)
  146. modifiers.each do |mod|
  147. code += "\n" + indent(" #{mod}", depth)
  148. end
  149. end
  150. 6 code += "\n" + indent(") {", depth)
  151. # Handle custom icons or default components
  152. # If icon is "circle" or selectedIcon is "checkmark.circle.fill", use default RadioButton
  153. 6 if (json_data['icon'] == 'circle' || !json_data['icon']) &&
  154. (json_data['selectedIcon'] == 'checkmark.circle.fill' || !json_data['selectedIcon'])
  155. # Use default RadioButton for standard radio appearance
  156. 3 code += "\n" + indent(" RadioButton(", depth)
  157. 3 code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  158. 3 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  159. 3 code += "\n" + indent(" )", depth)
  160. 3 elsif json_data['icon'] == 'square' &&
  161. (json_data['selectedIcon'] == 'checkmark.square.fill' || !json_data['selectedIcon'])
  162. # Use default Checkbox for square appearance
  163. 1 required_imports&.add(:checkbox)
  164. 1 code += "\n" + indent(" Checkbox(", depth)
  165. 1 code += "\n" + indent(" checked = data.#{selected_var} == \"#{id}\",", depth)
  166. 1 code += "\n" + indent(" onCheckedChange = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  167. 1 code += "\n" + indent(" )", depth)
  168. 2 elsif json_data['icon'] || json_data['selectedIcon']
  169. # Use IconButton with custom icons only for non-standard icons
  170. 2 required_imports&.add(:icon_button)
  171. 2 required_imports&.add(:icons)
  172. 2 icon = map_icon_name(json_data['icon'] || 'star')
  173. 2 selected_icon = map_icon_name(json_data['selectedIcon'] || 'star.fill')
  174. 2 code += "\n" + indent(" val isSelected = data.#{selected_var} == \"#{id}\"", depth)
  175. 2 code += "\n" + indent(" IconButton(", depth)
  176. 2 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  177. 2 code += "\n" + indent(" ) {", depth)
  178. 2 code += "\n" + indent(" Icon(", depth)
  179. 2 code += "\n" + indent(" imageVector = if (isSelected) #{selected_icon} else #{icon},", depth)
  180. 2 code += "\n" + indent(" contentDescription = \"#{text}\",", depth)
  181. 2 if json_data['selectedColor'] || json_data['tintColor']
  182. 1 color = json_data['selectedColor'] || json_data['tintColor']
  183. 1 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  184. 1 code += "\n" + indent(" tint = if (isSelected) #{selected_color} else Color.Gray", depth)
  185. else
  186. 1 code += "\n" + indent(" tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray", depth)
  187. end
  188. 2 code += "\n" + indent(" )", depth)
  189. 2 code += "\n" + indent(" }", depth)
  190. else
  191. # Default RadioButton
  192. code += "\n" + indent(" RadioButton(", depth)
  193. code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  194. code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  195. code += "\n" + indent(" )", depth)
  196. end
  197. # Add text label
  198. 6 if text && !text.empty?
  199. 6 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  200. # Add text with color
  201. 6 if json_data['fontColor'] || json_data['textColor']
  202. 1 text_color = json_data['fontColor'] || json_data['textColor']
  203. 1 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  204. 1 code += "\n" + indent(" Text(\"#{text}\", color = #{color_resolved})", depth)
  205. else
  206. # Default to black color
  207. 5 code += "\n" + indent(" Text(\"#{text}\", color = Color.Black)", depth)
  208. end
  209. end
  210. 6 code += "\n" + indent("}", depth)
  211. 6 code
  212. end
  213. 1 def self.generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  214. 3 items = json_data['items']
  215. 3 selected_value = json_data['selectedValue']
  216. # Add required import for clickable
  217. 3 required_imports&.add(:clickable)
  218. # Extract binding variable
  219. 3 selected_var = if selected_value && selected_value.match(/@\{([^}]+)\}/)
  220. 3 "data.#{$1}"
  221. else
  222. '""'
  223. end
  224. 3 code = indent("Column(", depth)
  225. # Build modifiers
  226. 3 modifiers = []
  227. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  228. 3 if modifiers.any?
  229. code += "\n" + indent(" modifier = Modifier", depth)
  230. modifiers.each do |mod|
  231. code += "\n" + indent(" #{mod}", depth)
  232. end
  233. end
  234. 3 code += "\n" + indent(") {", depth)
  235. # Add label if present
  236. 3 if json_data['text']
  237. 1 if json_data['fontColor'] || json_data['textColor']
  238. text_color = json_data['fontColor'] || json_data['textColor']
  239. color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  240. code += "\n" + indent(" Text(\"#{json_data['text']}\", color = #{color_resolved})", depth)
  241. else
  242. # Default to black color
  243. 1 code += "\n" + indent(" Text(\"#{json_data['text']}\", color = Color.Black)", depth)
  244. end
  245. 1 code += "\n" + indent(" Spacer(modifier = Modifier.height(8.dp))", depth)
  246. end
  247. # Generate radio items
  248. 3 items.each do |item|
  249. 7 code += "\n" + indent(" Row(", depth)
  250. 7 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  251. 7 code += "\n" + indent(" modifier = Modifier", depth)
  252. 7 code += "\n" + indent(" .fillMaxWidth()", depth)
  253. 7 code += "\n" + indent(" .clickable {", depth)
  254. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  255. 7 variable = $1
  256. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  257. end
  258. 7 code += "\n" + indent(" }", depth)
  259. 7 code += "\n" + indent(" ) {", depth)
  260. 7 code += "\n" + indent(" RadioButton(", depth)
  261. 7 code += "\n" + indent(" selected = #{selected_var} == \"#{item}\",", depth)
  262. 7 code += "\n" + indent(" onClick = {", depth)
  263. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  264. 7 variable = $1
  265. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  266. end
  267. 7 code += "\n" + indent(" }", depth)
  268. 7 code += "\n" + indent(" )", depth)
  269. 7 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  270. # Add text with black color
  271. 7 if json_data['fontColor'] || json_data['textColor']
  272. 2 text_color = json_data['fontColor'] || json_data['textColor']
  273. 2 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  274. 2 code += "\n" + indent(" Text(\"#{item}\", color = #{color_resolved})", depth)
  275. else
  276. # Default to black color
  277. 5 code += "\n" + indent(" Text(\"#{item}\", color = Color.Black)", depth)
  278. end
  279. 7 code += "\n" + indent(" }", depth)
  280. end
  281. 3 code += "\n" + indent("}", depth)
  282. 3 code
  283. end
  284. 1 def self.map_icon_name(icon_name)
  285. # Map iOS SF Symbols to Material Icons
  286. 9 icon_map = {
  287. 'circle' => 'Icons.Outlined.PanoramaFishEye', # Using PanoramaFishEye as it's a hollow circle
  288. 'checkmark.circle.fill' => 'Icons.Filled.CheckCircle',
  289. 'star' => 'Icons.Outlined.Star',
  290. 'star.fill' => 'Icons.Filled.Star',
  291. 'heart' => 'Icons.Outlined.FavoriteBorder',
  292. 'heart.fill' => 'Icons.Filled.Favorite',
  293. 'square' => 'Icons.Outlined.CheckBoxOutlineBlank',
  294. 'checkmark.square.fill' => 'Icons.Default.CheckBox' # Use Default.CheckBox instead of Filled.CheckBox
  295. }
  296. 9 icon_map[icon_name] || 'Icons.Outlined.Star' # Default fallback to star
  297. end
  298. 1 def self.indent(text, level)
  299. 400 return text if level == 0
  300. 179 spaces = ' ' * level
  301. 179 text.split("\n").map { |line|
  302. 179 line.empty? ? line : spaces + line
  303. }.join("\n")
  304. end
  305. end
  306. end
  307. end
  308. end

lib/compose/components/scrollview_component.rb

100.0% lines covered

43 relevant lines. 43 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ScrollViewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # スクロール方向の判定
  9. # horizontalScroll属性、orientation属性、またはchild要素の配置から判定
  10. 13 is_horizontal = false
  11. # 1. horizontalScroll属性を最優先
  12. 13 if json_data.key?('horizontalScroll')
  13. 2 is_horizontal = json_data['horizontalScroll']
  14. # 2. orientation属性を次に確認
  15. 11 elsif json_data.key?('orientation')
  16. 1 is_horizontal = json_data['orientation'] == 'horizontal'
  17. # 3. child要素の配置から判定
  18. 10 elsif json_data['child']
  19. 3 children = json_data['child']
  20. # childを配列として扱う
  21. 3 children = [children] unless children.is_a?(Array)
  22. # 配列の中から最初のViewコンポーネントを探す
  23. 6 first_view = children.find { |child| child.is_a?(Hash) && child['type'] == 'View' }
  24. 3 if first_view
  25. 1 is_horizontal = first_view['orientation'] == 'horizontal'
  26. end
  27. end
  28. # keyboardAvoidance属性の確認(デフォルトはtrue)
  29. 13 keyboard_avoidance = json_data['keyboardAvoidance'] != false
  30. 13 if is_horizontal
  31. 4 required_imports&.add(:lazy_row)
  32. 4 code = indent("LazyRow(", depth)
  33. else
  34. 9 required_imports&.add(:lazy_column)
  35. 9 code = indent("LazyColumn(", depth)
  36. end
  37. # Build modifiers
  38. 13 modifiers = []
  39. 13 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  40. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. 13 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. 13 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  43. # Apply keyboard avoidance at the end of modifier chain
  44. 13 if keyboard_avoidance
  45. 11 required_imports&.add(:ime_padding)
  46. 11 modifiers << ".imePadding()"
  47. end
  48. 13 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  49. 13 code += "\n" + indent(") {", depth)
  50. 13 code += "\n" + indent("item {", depth + 1)
  51. # Process children
  52. 13 children = json_data['child'] || []
  53. 13 children = [children] unless children.is_a?(Array)
  54. # Return structure for parent to process children
  55. {
  56. 13 code: code,
  57. children: children,
  58. closing: "\n" + indent("}", depth + 1) + "\n" + indent("}", depth),
  59. json_data: json_data
  60. }
  61. end
  62. 1 private
  63. 1 def self.indent(text, level)
  64. 65 return text if level == 0
  65. 26 spaces = ' ' * level
  66. 26 text.split("\n").map { |line|
  67. 26 line.empty? ? line : spaces + line
  68. }.join("\n")
  69. end
  70. end
  71. end
  72. end
  73. end

lib/compose/components/segment_component.rb

78.79% lines covered

165 relevant lines. 130 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SegmentComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 required_imports&.add(:segment)
  10. # Segment uses 'selectedIndex' or 'bind' for selected index
  11. # Track if the selected index is dynamic (from data binding) or static
  12. 22 is_dynamic_index = false
  13. 22 selected_index = if json_data['selectedIndex']
  14. 5 if json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  15. 3 variable = $1
  16. 3 is_dynamic_index = true
  17. 3 "data.#{variable}"
  18. else
  19. # Direct integer value - keep as integer for proper comparison
  20. 2 json_data['selectedIndex'].to_i
  21. end
  22. 17 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. 1 variable = $1
  24. 1 is_dynamic_index = true
  25. 1 "data.#{variable}"
  26. else
  27. 16 0 # Default to 0 as integer
  28. end
  29. # Support both 'items' and 'segments' attribute names
  30. 22 segments = json_data['items'] || json_data['segments'] || []
  31. 22 code = indent("Segment(", depth)
  32. # For display in Segment parameter, always output as string
  33. 22 selected_tab_param = is_dynamic_index ? selected_index : selected_index.to_s
  34. 22 code += "\n" + indent("selectedTabIndex = #{selected_tab_param},", depth + 1)
  35. # Add enabled state if specified
  36. 22 if json_data.key?('enabled')
  37. 2 enabled_value = json_data['enabled']
  38. 2 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  39. 1 code += "\n" + indent("enabled = data.#{$1},", depth + 1)
  40. else
  41. 1 code += "\n" + indent("enabled = #{enabled_value},", depth + 1)
  42. end
  43. end
  44. # Tab colors - only add if specified, otherwise use defaults from Configuration
  45. 22 colors_params = []
  46. # Background color (containerColor)
  47. 22 if json_data['backgroundColor']
  48. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  49. 1 colors_params << "containerColor = #{bg_color}"
  50. end
  51. # Normal text color (contentColor) - for unselected tabs
  52. 22 if json_data['normalColor']
  53. 3 normal_color = Helpers::ResourceResolver.process_color(json_data['normalColor'], required_imports)
  54. 3 colors_params << "contentColor = #{normal_color}"
  55. end
  56. # Selected text color (selectedContentColor)
  57. 22 if json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  58. 4 color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  59. 4 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  60. 4 colors_params << "selectedContentColor = #{selected_color}"
  61. end
  62. # Indicator color - only if specified
  63. 22 if json_data['indicatorColor']
  64. 1 indicator_color = Helpers::ResourceResolver.process_color(json_data['indicatorColor'], required_imports)
  65. 1 colors_params << "indicatorColor = #{indicator_color}"
  66. end
  67. 22 if colors_params.any?
  68. 7 code += "\n" + indent(colors_params.join(",\n"), depth + 1) + ","
  69. end
  70. # Build modifiers
  71. 22 modifiers = []
  72. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  73. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  74. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  75. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  76. 22 code += "\n" + indent(") {", depth)
  77. # Generate tabs
  78. 22 if segments.is_a?(Array)
  79. 21 segments.each_with_index do |segment, index|
  80. 41 code += "\n" + indent("Tab(", depth + 1)
  81. # For selected comparison, handle both dynamic and static cases
  82. 41 selected_comparison = is_dynamic_index ? "(#{selected_index} == #{index})" : (selected_index == index).to_s
  83. 41 code += "\n" + indent("selected = #{selected_comparison},", depth + 2)
  84. # Add enabled state to Tab if segment is disabled
  85. 41 if json_data.key?('enabled')
  86. 4 enabled_value = json_data['enabled']
  87. 4 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  88. 2 code += "\n" + indent("enabled = data.#{$1},", depth + 2)
  89. else
  90. 2 code += "\n" + indent("enabled = #{enabled_value},", depth + 2)
  91. end
  92. end
  93. 41 code += "\n" + indent("onClick = {", depth + 2)
  94. # Check if we have a binding variable
  95. 41 has_binding = false
  96. 41 binding_variable = nil
  97. 41 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  98. 6 has_binding = true
  99. 6 binding_variable = $1
  100. 35 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  101. 2 has_binding = true
  102. 2 binding_variable = $1
  103. end
  104. # Generate onClick handler
  105. # onValueChange (camelCase) -> binding format only (@{functionName})
  106. 41 if json_data['onValueChange']
  107. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  108. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  109. 2 code += "\n" + indent("viewModel.#{method_name}(#{index})", depth + 3)
  110. else
  111. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 3)
  112. end
  113. 39 elsif has_binding
  114. # Update the bound variable
  115. 8 code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to #{index}))", depth + 3)
  116. else
  117. # No action if selectedIndex is a static value with no binding
  118. 31 code += "\n" + indent("// Static selected index", depth + 3)
  119. end
  120. 41 code += "\n" + indent("},", depth + 2)
  121. # Generate text with color based on selection
  122. # Store color info for later use
  123. 41 normal_color = json_data['normalColor']
  124. 41 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  125. 41 if normal_color || selected_color
  126. # Need to handle text color based on selection
  127. 10 code += "\n" + indent("text = {", depth + 2)
  128. 10 code += "\n" + indent("Text(", depth + 3)
  129. 10 code += "\n" + indent("\"#{segment}\",", depth + 4)
  130. # Use conditional color based on selection
  131. 10 if is_dynamic_index
  132. 2 if selected_color && normal_color
  133. 2 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  134. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  135. 2 code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else #{normal_resolved}", depth + 4)
  136. elsif selected_color
  137. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  138. code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else Color.Unspecified", depth + 4)
  139. elsif normal_color
  140. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  141. code += "\n" + indent("color = if (#{selected_index} == #{index}) Color.Unspecified else #{normal_resolved}", depth + 4)
  142. end
  143. else
  144. # Static index
  145. 8 is_selected = (selected_index == index)
  146. 8 if is_selected && selected_color
  147. 3 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  148. 3 code += "\n" + indent("color = #{selected_resolved}", depth + 4)
  149. 5 elsif !is_selected && normal_color
  150. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  151. 2 code += "\n" + indent("color = #{normal_resolved}", depth + 4)
  152. end
  153. end
  154. 10 code += "\n" + indent(")", depth + 3)
  155. 10 code += "\n" + indent("}", depth + 2)
  156. else
  157. 31 code += "\n" + indent("text = { Text(\"#{segment}\") }", depth + 2)
  158. end
  159. 41 code += "\n" + indent(")", depth + 1)
  160. end
  161. 1 elsif segments.is_a?(String) && segments.match(/@\{([^}]+)\}/)
  162. # Dynamic segments from data binding
  163. 1 segments_var = $1
  164. 1 code += "\n" + indent("data.#{segments_var}.forEachIndexed { index, segment ->", depth + 1)
  165. 1 code += "\n" + indent("Tab(", depth + 2)
  166. # For dynamic segments, selected_index comparison depends on whether the index itself is dynamic
  167. 1 selected_comparison = is_dynamic_index ? "(#{selected_index} == index)" : "(#{selected_index} == index)"
  168. 1 code += "\n" + indent("selected = #{selected_comparison},", depth + 3)
  169. # Add enabled state to Tab if segment is disabled
  170. 1 if json_data.key?('enabled')
  171. enabled_value = json_data['enabled']
  172. if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  173. code += "\n" + indent("enabled = data.#{$1},", depth + 3)
  174. else
  175. code += "\n" + indent("enabled = #{enabled_value},", depth + 3)
  176. end
  177. end
  178. 1 code += "\n" + indent("onClick = {", depth + 3)
  179. # Check if we have a binding variable
  180. 1 has_binding = false
  181. 1 binding_variable = nil
  182. 1 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  183. has_binding = true
  184. binding_variable = $1
  185. 1 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  186. has_binding = true
  187. binding_variable = $1
  188. end
  189. # Generate onClick handler
  190. # onValueChange (camelCase) -> binding format only (@{functionName})
  191. 1 if json_data['onValueChange']
  192. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  193. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  194. code += "\n" + indent("viewModel.#{method_name}(index)", depth + 4)
  195. else
  196. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
  197. end
  198. 1 elsif has_binding
  199. # Update the bound variable
  200. code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to index))", depth + 4)
  201. else
  202. # No action if selectedIndex is a static value with no binding
  203. 1 code += "\n" + indent("// Static selected index", depth + 4)
  204. end
  205. 1 code += "\n" + indent("},", depth + 3)
  206. # Generate text with color based on selection for dynamic segments
  207. 1 normal_color = json_data['normalColor']
  208. 1 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  209. 1 if normal_color || selected_color
  210. code += "\n" + indent("text = {", depth + 3)
  211. code += "\n" + indent("Text(", depth + 4)
  212. code += "\n" + indent("segment,", depth + 5)
  213. # Use conditional color based on selection
  214. if selected_color && normal_color
  215. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  216. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  217. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else #{normal_resolved}", depth + 5)
  218. elsif selected_color
  219. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  220. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else Color.Unspecified", depth + 5)
  221. elsif normal_color
  222. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  223. code += "\n" + indent("color = if (#{selected_comparison}) Color.Unspecified else #{normal_resolved}", depth + 5)
  224. end
  225. code += "\n" + indent(")", depth + 4)
  226. code += "\n" + indent("}", depth + 3)
  227. else
  228. 1 code += "\n" + indent("text = { Text(segment) }", depth + 3)
  229. end
  230. 1 code += "\n" + indent(")", depth + 2)
  231. 1 code += "\n" + indent("}", depth + 1)
  232. end
  233. 22 code += "\n" + indent("}", depth)
  234. 22 code
  235. end
  236. 1 private
  237. 1 def self.indent(text, level)
  238. 446 return text if level == 0
  239. 379 spaces = ' ' * level
  240. 379 text.split("\n").map { |line|
  241. 381 line.empty? ? line : spaces + line
  242. }.join("\n")
  243. end
  244. end
  245. end
  246. end
  247. end

lib/compose/components/selectbox_component.rb

89.08% lines covered

119 relevant lines. 106 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SelectBoxComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 28 required_imports&.add(:selectbox_component)
  10. # Check if this is a date picker
  11. 28 is_date_picker = json_data['selectItemType'] == 'Date'
  12. # SelectBox uses 'selectedItem', 'selectedDate', or 'bind' for selected value
  13. # For date pickers, selectedDate takes priority
  14. 28 selected = if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
  15. variable = $1
  16. "data.#{variable}"
  17. 28 elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  18. 1 variable = $1
  19. 1 "data.#{variable}"
  20. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  21. 1 variable = $1
  22. 1 "data.#{variable}"
  23. else
  24. 26 '""'
  25. end
  26. # Use DateSelectBox for date type
  27. 28 if is_date_picker
  28. 9 required_imports&.add(:date_selectbox_component)
  29. 9 code = indent("DateSelectBox(", depth)
  30. else
  31. 19 code = indent("SelectBox(", depth)
  32. end
  33. 28 code += "\n" + indent("value = #{selected},", depth + 1)
  34. # Handle onValueChange callback
  35. # For date pickers, check selectedDate first
  36. 28 binding_variable = nil
  37. 28 if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
  38. binding_variable = $1
  39. 28 elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  40. 1 binding_variable = $1
  41. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  42. 1 binding_variable = $1
  43. end
  44. 28 if binding_variable
  45. 2 code += "\n" + indent("onValueChange = { newValue ->", depth + 1)
  46. 2 code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue))", depth + 2)
  47. 2 code += "\n" + indent("},", depth + 1)
  48. else
  49. 26 code += "\n" + indent("onValueChange = { },", depth + 1)
  50. end
  51. # For date picker, add date-specific parameters
  52. 28 if is_date_picker
  53. # Date picker mode (date, time, dateAndTime)
  54. 9 if json_data['datePickerMode']
  55. 1 code += "\n" + indent("datePickerMode = \"#{json_data['datePickerMode']}\",", depth + 1)
  56. end
  57. # Date picker style
  58. 9 if json_data['datePickerStyle']
  59. 1 code += "\n" + indent("datePickerStyle = \"#{json_data['datePickerStyle']}\",", depth + 1)
  60. end
  61. # Date format (or dateStringFormat)
  62. 9 date_format = json_data['dateFormat'] || json_data['dateStringFormat']
  63. 9 if date_format
  64. 2 code += "\n" + indent("dateFormat = \"#{date_format}\",", depth + 1)
  65. end
  66. # Minute interval for time pickers
  67. 9 if json_data['minuteInterval']
  68. 1 code += "\n" + indent("minuteInterval = #{json_data['minuteInterval']},", depth + 1)
  69. end
  70. # Minimum date
  71. 9 if json_data['minimumDate']
  72. 1 code += "\n" + indent("minimumDate = \"#{json_data['minimumDate']}\",", depth + 1)
  73. end
  74. # Maximum date
  75. 9 if json_data['maximumDate']
  76. 1 code += "\n" + indent("maximumDate = \"#{json_data['maximumDate']}\",", depth + 1)
  77. end
  78. else
  79. # Options (use 'items' or 'options') - only for non-date SelectBox
  80. 19 options_data = json_data['items'] || json_data['options']
  81. 19 if options_data
  82. 6 if options_data.is_a?(String) && options_data.match(/@\{([^}]+)\}/)
  83. # Dynamic options from data binding
  84. 1 options_var = $1
  85. 1 code += "\n" + indent("options = data.#{options_var},", depth + 1)
  86. 5 elsif options_data.is_a?(Array)
  87. # Static options array
  88. 5 options_list = options_data.map do |option|
  89. 12 if option.is_a?(Hash)
  90. 2 "\"#{option['label'] || option['value']}\""
  91. else
  92. 10 "\"#{option}\""
  93. end
  94. end.join(", ")
  95. 5 code += "\n" + indent("options = listOf(#{options_list}),", depth + 1)
  96. else
  97. code += "\n" + indent("options = emptyList(),", depth + 1)
  98. end
  99. else
  100. 13 code += "\n" + indent("options = emptyList(),", depth + 1)
  101. end
  102. end
  103. # Add placeholder/hint if specified
  104. 28 if json_data['hint']
  105. 1 code += "\n" + indent("placeholder = \"#{json_data['hint']}\",", depth + 1)
  106. 27 elsif json_data['placeholder']
  107. 1 code += "\n" + indent("placeholder = \"#{json_data['placeholder']}\",", depth + 1)
  108. end
  109. # Add enabled state if specified
  110. 28 if json_data['disabled']
  111. 1 code += "\n" + indent("enabled = false,", depth + 1)
  112. 27 elsif json_data['enabled'] == false
  113. 1 code += "\n" + indent("enabled = false,", depth + 1)
  114. end
  115. # Add style parameters
  116. 28 if json_data['background']
  117. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  118. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  119. end
  120. 28 if json_data['borderColor']
  121. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  122. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  123. end
  124. 28 if json_data['fontColor']
  125. 1 text_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  126. 1 code += "\n" + indent("textColor = #{text_color},", depth + 1)
  127. end
  128. 28 if json_data['hintColor']
  129. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  130. 1 code += "\n" + indent("hintColor = #{hint_color},", depth + 1)
  131. end
  132. 28 if json_data['cornerRadius']
  133. 1 code += "\n" + indent("cornerRadius = #{json_data['cornerRadius']},", depth + 1)
  134. end
  135. # Font styling
  136. 28 if json_data['fontSize']
  137. code += "\n" + indent("fontSize = #{json_data['fontSize']},", depth + 1)
  138. end
  139. 28 if json_data['font']
  140. font_weight = case json_data['font'].to_s.downcase
  141. when 'bold'
  142. 'FontWeight.Bold'
  143. when 'semibold'
  144. 'FontWeight.SemiBold'
  145. when 'medium'
  146. 'FontWeight.Medium'
  147. when 'light'
  148. 'FontWeight.Light'
  149. when 'thin'
  150. 'FontWeight.Thin'
  151. else
  152. 'FontWeight.Normal'
  153. end
  154. code += "\n" + indent("fontWeight = #{font_weight},", depth + 1)
  155. end
  156. # Add cancel button background color if specified
  157. 28 if json_data['cancelButtonBackgroundColor']
  158. 1 cancel_bg = Helpers::ResourceResolver.process_color(json_data['cancelButtonBackgroundColor'], required_imports)
  159. 1 code += "\n" + indent("cancelButtonBackgroundColor = #{cancel_bg},", depth + 1)
  160. end
  161. # Add cancel button text color if specified
  162. 28 if json_data['cancelButtonTextColor']
  163. 1 cancel_text = Helpers::ResourceResolver.process_color(json_data['cancelButtonTextColor'], required_imports)
  164. 1 code += "\n" + indent("cancelButtonTextColor = #{cancel_text},", depth + 1)
  165. end
  166. # Build modifiers
  167. 28 modifiers = []
  168. # Ensure fillMaxWidth if width is not specified for date pickers
  169. 28 if is_date_picker && !json_data['width']
  170. 9 modifiers << ".fillMaxWidth()"
  171. end
  172. 28 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  173. 28 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  174. 28 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  175. 28 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  176. 28 if modifiers.any? && !modifiers.include?('SKIP_RENDER')
  177. 9 code += Helpers::ModifierBuilder.format(modifiers, depth)
  178. end
  179. 28 code += "\n" + indent(")", depth)
  180. 28 code
  181. end
  182. 1 private
  183. 1 def self.indent(text, level)
  184. 155 return text if level == 0
  185. 98 spaces = ' ' * level
  186. 98 text.split("\n").map { |line|
  187. 98 line.empty? ? line : spaces + line
  188. }.join("\n")
  189. end
  190. end
  191. end
  192. end
  193. end

lib/compose/components/slider_component.rb

98.59% lines covered

71 relevant lines. 70 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SliderComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Slider uses 'value' or 'bind' for binding
  10. 23 value = if json_data['value']
  11. 2 if json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  12. 1 variable = $1
  13. 1 "data.#{variable}.toFloat()"
  14. else
  15. # Direct value
  16. 1 "#{json_data['value']}f"
  17. end
  18. 21 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 1 variable = $1
  20. 1 "data.#{variable}.toFloat()"
  21. else
  22. 20 '0f'
  23. end
  24. # Support both naming conventions for min/max
  25. 23 min_value = json_data['minimumValue'] || json_data['min'] || 0
  26. 23 max_value = json_data['maximumValue'] || json_data['max'] || 100
  27. 23 code = indent("Slider(", depth)
  28. 23 code += "\n" + indent("value = #{value},", depth + 1)
  29. # onValueChange handler
  30. 23 binding_variable = nil
  31. 23 if json_data['value'] && json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  32. 1 binding_variable = $1
  33. 22 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  34. 1 binding_variable = $1
  35. end
  36. 23 if json_data['onValueChange']
  37. # onValueChange (camelCase) -> binding format only (@{functionName})
  38. 1 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  39. 1 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  40. 1 code += "\n" + indent("onValueChange = { viewModel.#{method_name}(it) },", depth + 1)
  41. else
  42. code += "\n" + indent("onValueChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
  43. end
  44. 22 elsif binding_variable
  45. # Update the bound variable - check if it's Int or Double/Float based on the data type
  46. 2 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue.toDouble())) },", depth + 1)
  47. else
  48. 20 code += "\n" + indent("onValueChange = { },", depth + 1)
  49. end
  50. # Value range
  51. 23 code += "\n" + indent("valueRange = #{min_value}f..#{max_value}f,", depth + 1)
  52. # Steps
  53. 23 if json_data['step'] && json_data['step'] > 0
  54. 1 steps = ((max_value - min_value) / json_data['step'].to_f).to_i - 1
  55. 1 code += "\n" + indent("steps = #{steps},", depth + 1) if steps > 0
  56. end
  57. # Build modifiers
  58. 23 modifiers = []
  59. 23 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  60. 23 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  61. 23 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  62. 23 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  63. # Slider colors
  64. 23 if json_data['minimumTrackTintColor'] || json_data['maximumTrackTintColor'] || json_data['thumbTintColor']
  65. 4 required_imports&.add(:slider_colors)
  66. 4 colors_params = []
  67. 4 if json_data['thumbTintColor']
  68. 2 thumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  69. 2 colors_params << "thumbColor = #{thumbcolor_resolved}"
  70. end
  71. 4 if json_data['minimumTrackTintColor']
  72. 2 activetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['minimumTrackTintColor'], required_imports)
  73. 2 colors_params << "activeTrackColor = #{activetrackcolor_resolved}"
  74. end
  75. 4 if json_data['maximumTrackTintColor']
  76. 2 inactivetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['maximumTrackTintColor'], required_imports)
  77. 2 colors_params << "inactiveTrackColor = #{inactivetrackcolor_resolved}"
  78. end
  79. 4 if colors_params.any?
  80. 4 code += ",\n" + indent("colors = SliderDefaults.colors(", depth + 1)
  81. 10 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  82. 4 code += "\n" + indent(")", depth + 1)
  83. end
  84. end
  85. # Handle enabled attribute
  86. 23 if json_data.key?('enabled')
  87. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  88. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  89. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  90. else
  91. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  92. end
  93. end
  94. 23 code += "\n" + indent(")", depth)
  95. 23 code
  96. end
  97. 1 private
  98. 1 def self.indent(text, level)
  99. 136 return text if level == 0
  100. 89 spaces = ' ' * level
  101. 89 text.split("\n").map { |line|
  102. 90 line.empty? ? line : spaces + line
  103. }.join("\n")
  104. end
  105. end
  106. end
  107. end
  108. end

lib/compose/components/switch_component.rb

38.97% lines covered

136 relevant lines. 53 lines covered and 83 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. # SwitchComponent handles both Switch (primary) and Toggle (alias) component types
  8. # Switch is the primary component name. Toggle is supported as an alias for backward compatibility.
  9. 1 class SwitchComponent
  10. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  11. # Switch/Toggle uses 'isOn', 'value', 'checked', or 'bind' for binding
  12. # Priority: isOn > value > checked > bind
  13. 7 state_attr = json_data['isOn'] || json_data['value'] || json_data['checked']
  14. 7 checked = if state_attr
  15. if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
  16. variable = $1
  17. "data.#{variable}"
  18. else
  19. # Direct boolean value
  20. state_attr.to_s
  21. end
  22. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. variable = $1
  24. "data.#{variable}"
  25. else
  26. 7 'false'
  27. end
  28. 7 has_label = json_data['labelAttributes']
  29. 7 if has_label
  30. generate_with_label(json_data, depth, required_imports, parent_type, checked)
  31. else
  32. 7 generate_switch_only(json_data, depth, required_imports, parent_type, checked)
  33. end
  34. end
  35. 1 def self.generate_switch_only(json_data, depth, required_imports, parent_type, checked)
  36. 7 code = indent("Switch(", depth)
  37. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  38. # onCheckedChange handler
  39. 7 binding_variable = nil
  40. 7 state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
  41. 7 if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  42. binding_variable = $1
  43. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  44. binding_variable = $1
  45. end
  46. # onToggle and onValueChange are aliases
  47. # onValueChange (camelCase) -> binding format only (@{functionName})
  48. 7 handler = json_data['onValueChange'] || json_data['onToggle']
  49. 7 if handler
  50. # onValueChange must be binding format
  51. if Helpers::ModifierBuilder.is_binding?(handler)
  52. method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
  53. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
  54. else
  55. code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} },", depth + 1)
  56. end
  57. 7 elsif binding_variable
  58. # Update the bound variable
  59. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
  60. else
  61. 7 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  62. end
  63. # Build modifiers
  64. 7 modifiers = []
  65. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  66. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  67. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  68. # Add weight modifier if in Row or Column
  69. 7 if parent_type == 'Row' || parent_type == 'Column'
  70. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  71. end
  72. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  73. # Switch colors
  74. # tint and tintColor are aliases for onTintColor
  75. 7 track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
  76. 7 if track_color || json_data['thumbTintColor']
  77. 1 required_imports&.add(:switch_colors)
  78. 1 colors_params = []
  79. 1 if track_color
  80. 1 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
  81. 1 colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
  82. end
  83. 1 if json_data['thumbTintColor']
  84. checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  85. colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
  86. end
  87. 1 if colors_params.any?
  88. 1 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  89. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  90. 1 code += "\n" + indent(")", depth + 1)
  91. end
  92. end
  93. # Handle enabled attribute
  94. 7 if json_data.key?('enabled')
  95. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  96. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  97. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  98. else
  99. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  100. end
  101. end
  102. 7 code += "\n" + indent(")", depth)
  103. 7 code
  104. end
  105. 1 def self.generate_with_label(json_data, depth, required_imports, parent_type, checked)
  106. label_attrs = json_data['labelAttributes']
  107. # Row container for label + switch
  108. code = indent("Row(", depth)
  109. code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
  110. # Build modifiers for Row
  111. modifiers = []
  112. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  113. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  114. if parent_type == 'Row' || parent_type == 'Column'
  115. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  116. end
  117. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  118. code += "\n" + indent(") {", depth)
  119. # Label Text
  120. label_text = label_attrs['text'] || ''
  121. code += "\n" + indent("Text(", depth + 1)
  122. code += "\n" + indent("text = \"#{label_text}\",", depth + 2)
  123. # Font attributes
  124. if label_attrs['fontSize']
  125. code += "\n" + indent("fontSize = #{label_attrs['fontSize']}.sp,", depth + 2)
  126. end
  127. if label_attrs['fontColor']
  128. font_color = Helpers::ResourceResolver.process_color(label_attrs['fontColor'], required_imports)
  129. code += "\n" + indent("color = #{font_color},", depth + 2)
  130. end
  131. if label_attrs['font']
  132. font_weight = label_attrs['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
  133. code += "\n" + indent("fontWeight = #{font_weight},", depth + 2)
  134. end
  135. code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
  136. code += "\n" + indent(")", depth + 1)
  137. # Switch
  138. code += "\n" + indent("Switch(", depth + 1)
  139. code += "\n" + indent("checked = #{checked},", depth + 2)
  140. # onCheckedChange handler
  141. binding_variable = nil
  142. state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
  143. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  144. binding_variable = $1
  145. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  146. binding_variable = $1
  147. end
  148. handler = json_data['onValueChange'] || json_data['onToggle']
  149. if handler
  150. # onValueChange (camelCase) -> binding format only (@{functionName})
  151. if Helpers::ModifierBuilder.is_binding?(handler)
  152. method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
  153. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
  154. else
  155. code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} }", depth + 2)
  156. end
  157. elsif binding_variable
  158. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
  159. else
  160. code += "\n" + indent("onCheckedChange = { }", depth + 2)
  161. end
  162. # Switch colors
  163. track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
  164. if track_color || json_data['thumbTintColor']
  165. required_imports&.add(:switch_colors)
  166. colors_params = []
  167. if track_color
  168. checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
  169. colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
  170. end
  171. if json_data['thumbTintColor']
  172. checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  173. colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
  174. end
  175. if colors_params.any?
  176. code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 2)
  177. code += "\n" + colors_params.map { |param| indent(param, depth + 3) }.join(",\n")
  178. code += "\n" + indent(")", depth + 2)
  179. end
  180. end
  181. # Handle enabled attribute
  182. if json_data.key?('enabled')
  183. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  184. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  185. code += ",\n" + indent("enabled = data.#{variable}", depth + 2)
  186. else
  187. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 2)
  188. end
  189. end
  190. code += "\n" + indent(")", depth + 1)
  191. code += "\n" + indent("}", depth)
  192. code
  193. end
  194. 1 private
  195. 1 def self.indent(text, level)
  196. 31 return text if level == 0
  197. 17 spaces = ' ' * level
  198. 17 text.split("\n").map { |line|
  199. 17 line.empty? ? line : spaces + line
  200. }.join("\n")
  201. end
  202. end
  203. end
  204. end
  205. end

lib/compose/components/table_component.rb

100.0% lines covered

108 relevant lines. 108 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class TableComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 21 required_imports&.add(:lazy_column)
  9. # Table uses data binding for items
  10. 21 items = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 20 elsif json_data['items'] && json_data['items'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}"
  16. else
  17. 19 'emptyList()'
  18. end
  19. 21 code = indent("LazyColumn(", depth)
  20. # Content padding
  21. 21 if json_data['contentPadding']
  22. 2 padding = json_data['contentPadding']
  23. 2 if padding.is_a?(Array) && padding.length == 4
  24. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
  25. 1 elsif padding.is_a?(Numeric)
  26. 1 code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
  27. end
  28. end
  29. # Vertical arrangement (spacing between rows)
  30. 21 if json_data['rowSpacing'] || json_data['spacing']
  31. 2 required_imports&.add(:arrangement)
  32. 2 spacing = json_data['rowSpacing'] || json_data['spacing'] || 0
  33. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
  34. end
  35. # Build modifiers
  36. 21 modifiers = []
  37. 21 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 21 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  41. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  42. 21 code += "\n" + indent(") {", depth)
  43. # Table header if specified
  44. 21 if json_data['header']
  45. 4 code += "\n" + indent("item {", depth + 1)
  46. 4 code += generate_header_row(json_data['header'], depth + 2, required_imports)
  47. 4 code += "\n" + indent("}", depth + 1)
  48. # Divider after header
  49. 4 if json_data['separatorStyle'] != 'none'
  50. 3 code += "\n" + indent("item {", depth + 1)
  51. 3 code += "\n" + indent("Divider(", depth + 2)
  52. 3 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  53. 3 code += "\n" + indent("thickness = 1.dp", depth + 3)
  54. 3 code += "\n" + indent(")", depth + 2)
  55. 3 code += "\n" + indent("}", depth + 1)
  56. end
  57. end
  58. # Table rows
  59. 21 code += "\n" + indent("items(#{items}) { item ->", depth + 1)
  60. # Row content
  61. 21 if json_data['cell']
  62. # Custom cell template
  63. 1 cell_content = generate_table_cell(json_data['cell'], depth + 2, required_imports)
  64. 1 code += "\n" + cell_content
  65. else
  66. # Default row
  67. 20 code += generate_default_row(json_data, depth + 2, required_imports)
  68. end
  69. # Separator between rows
  70. 21 if json_data['separatorStyle'] != 'none'
  71. 19 code += "\n" + indent("Divider(", depth + 2)
  72. # Separator inset
  73. 19 if json_data['separatorInset']
  74. 2 inset = json_data['separatorInset']
  75. 2 if inset.is_a?(Hash)
  76. 2 start_padding = inset['left'] || inset['start'] || 0
  77. 2 code += "\n" + indent("modifier = Modifier.padding(start = #{start_padding}.dp),", depth + 3)
  78. end
  79. end
  80. 19 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  81. 19 code += "\n" + indent("thickness = 0.5.dp", depth + 3)
  82. 19 code += "\n" + indent(")", depth + 2)
  83. end
  84. 21 code += "\n" + indent("}", depth + 1)
  85. 21 code += "\n" + indent("}", depth)
  86. 21 code
  87. end
  88. 1 private
  89. 1 def self.generate_header_row(header_data, depth, required_imports)
  90. 4 code = indent("Row(", depth)
  91. 4 code += "\n" + indent("modifier = Modifier", depth + 1)
  92. 4 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  93. 4 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp),", depth + 1)
  94. 4 code += "\n" + indent("horizontalArrangement = Arrangement.SpaceBetween", depth + 1)
  95. 4 code += "\n" + indent(") {", depth)
  96. 4 if header_data.is_a?(Array)
  97. 3 header_data.each do |column|
  98. 5 code += "\n" + indent("Text(", depth + 1)
  99. 5 code += "\n" + indent("text = \"#{column}\",", depth + 2)
  100. 5 code += "\n" + indent("fontWeight = FontWeight.Bold,", depth + 2)
  101. 5 code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
  102. 5 code += "\n" + indent(")", depth + 1)
  103. end
  104. else
  105. 1 code += "\n" + indent("Text(text = \"Header\", fontWeight = FontWeight.Bold)", depth + 1)
  106. end
  107. 4 code += "\n" + indent("}", depth)
  108. 4 code
  109. end
  110. 1 def self.generate_table_cell(cell_data, depth, required_imports)
  111. 1 code = indent("Row(", depth)
  112. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  113. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  114. 1 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  115. 1 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp)", depth + 1)
  116. 1 code += "\n" + indent(") {", depth)
  117. # Cell content based on template
  118. 1 code += "\n" + indent("// Custom cell rendering", depth + 1)
  119. 1 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  120. 1 code += "\n" + indent("}", depth)
  121. 1 code
  122. end
  123. 1 def self.generate_default_row(json_data, depth, required_imports)
  124. 20 row_height = json_data['rowHeight'] || 60
  125. 20 code = "\n" + indent("Row(", depth)
  126. 20 code += "\n" + indent("modifier = Modifier", depth + 1)
  127. 20 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  128. 20 code += "\n" + indent(" .height(#{row_height}.dp)", depth + 1)
  129. 20 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  130. 20 code += "\n" + indent(" .padding(horizontal = 16.dp),", depth + 1)
  131. 20 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  132. 20 code += "\n" + indent(") {", depth)
  133. 20 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  134. 20 code += "\n" + indent("}", depth)
  135. 20 code
  136. end
  137. 1 def self.indent(text, level)
  138. 479 return text if level == 0
  139. 415 spaces = ' ' * level
  140. 415 text.split("\n").map { |line|
  141. 417 line.empty? ? line : spaces + line
  142. }.join("\n")
  143. end
  144. end
  145. end
  146. end
  147. end

lib/compose/components/tabview_component.rb

91.23% lines covered

57 relevant lines. 52 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TabviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TabView maps to TabRow with Tab items in Compose
  10. 12 required_imports&.add(:tab_row)
  11. 12 required_imports&.add(:remember_state)
  12. # Generate state variable for selected tab
  13. 12 state_var = "selectedTab_#{Time.now.to_i}_#{rand(1000)}"
  14. 12 code = indent("// Tab view with content", depth)
  15. 12 code += "\n" + indent("var #{state_var} by remember { mutableStateOf(0) }", depth)
  16. 12 code += "\n\n" + indent("Column(", depth)
  17. # Column modifiers
  18. 12 modifiers = []
  19. 12 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  20. 12 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  21. 12 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  22. 12 code += Helpers::ModifierBuilder.format(modifiers, depth)
  23. 12 code += "\n" + indent(") {", depth)
  24. # TabRow
  25. 12 code += "\n" + indent("TabRow(", depth + 1)
  26. 12 code += "\n" + indent("selectedTabIndex = #{state_var},", depth + 2)
  27. # TabRow modifiers
  28. 12 tab_modifiers = []
  29. 12 if json_data['backgroundColor']
  30. tab_modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['backgroundColor']}', required_imports))"
  31. end
  32. 12 if tab_modifiers.any?
  33. code += "\n" + indent("modifier = Modifier", depth + 2)
  34. tab_modifiers.each do |mod|
  35. code += "\n" + indent(mod, depth + 3)
  36. end
  37. code += ","
  38. end
  39. 12 code += "\n" + indent(") {", depth + 1)
  40. # Generate tabs from items
  41. 12 if json_data['items'] && json_data['items'].is_a?(Array)
  42. 12 json_data['items'].each_with_index do |item, index|
  43. 16 title = item['title'] || "Tab #{index + 1}"
  44. 16 code += "\n" + indent("Tab(", depth + 2)
  45. 16 code += "\n" + indent("selected = #{state_var} == #{index},", depth + 3)
  46. 16 code += "\n" + indent("onClick = { #{state_var} = #{index} },", depth + 3)
  47. 16 code += "\n" + indent("text = { Text(\"#{title}\") }", depth + 3)
  48. 16 code += "\n" + indent(")", depth + 2)
  49. end
  50. end
  51. 12 code += "\n" + indent("}", depth + 1)
  52. # Tab content using when expression
  53. 12 if json_data['items'] && json_data['items'].is_a?(Array)
  54. 12 code += "\n\n" + indent("// Tab content", depth + 1)
  55. 12 code += "\n" + indent("when (#{state_var}) {", depth + 1)
  56. 12 json_data['items'].each_with_index do |item, index|
  57. 16 code += "\n" + indent("#{index} -> {", depth + 2)
  58. # Content for each tab
  59. 16 if item['child']
  60. 1 code += "\n" + indent("// Content for tab #{index}", depth + 3)
  61. # Note: Actual child content would be generated by the parent
  62. else
  63. 15 code += "\n" + indent("Text(\"Content for #{item['title'] || "Tab #{index + 1}"}\")", depth + 3)
  64. end
  65. 16 code += "\n" + indent("}", depth + 2)
  66. end
  67. 12 code += "\n" + indent("}", depth + 1)
  68. end
  69. 12 code += "\n" + indent("}", depth)
  70. 12 code
  71. end
  72. 1 private
  73. 1 def self.indent(text, level)
  74. 275 return text if level == 0
  75. 214 spaces = ' ' * level
  76. 214 text.split("\n").map { |line|
  77. 214 line.empty? ? line : spaces + line
  78. }.join("\n")
  79. end
  80. end
  81. end
  82. end
  83. end

lib/compose/components/text_component.rb

68.67% lines covered

332 relevant lines. 228 lines covered and 104 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/visibility_helper'
  4. 1 require_relative '../helpers/resource_resolver'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 module Components
  8. # Text Component Generator
  9. #
  10. # NOTE: Label is the primary component name in JsonUI.
  11. # Text is supported as an alias for backward compatibility.
  12. # Both "type": "Label" and "type": "Text" work identically.
  13. #
  14. 1 class TextComponent
  15. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  16. # Check if component should be skipped entirely (static gone/hidden)
  17. 62 return "" if Helpers::VisibilityHelper.should_skip_render?(json_data)
  18. # Check if we need to use PartialAttributesText for partial attributes
  19. 61 if json_data['partialAttributes'] && json_data['partialAttributes'].any?
  20. 8 return generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  21. end
  22. # Check if we need to use PartialAttributesText for linkable attribute
  23. 53 if json_data['linkable']
  24. 9 return generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  25. end
  26. 44 text = Helpers::ResourceResolver.process_text(json_data['text'] || '', required_imports)
  27. 44 component_code = indent("Text(", depth)
  28. 44 component_code += "\n" + indent("text = #{text},", depth + 1)
  29. # Font size
  30. 44 if json_data['fontSize']
  31. 2 component_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 1)
  32. end
  33. # Font color (official attribute)
  34. 44 if json_data['fontColor']
  35. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  36. 1 component_code += "\n" + indent("color = #{color_value},", depth + 1) if color_value
  37. end
  38. # Font weight values that should use system font weight
  39. 44 weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
  40. 44 weight_mapping = {
  41. 'thin' => 'Thin',
  42. 'extralight' => 'ExtraLight',
  43. 'light' => 'Light',
  44. 'normal' => 'Normal',
  45. 'medium' => 'Medium',
  46. 'semibold' => 'SemiBold',
  47. 'bold' => 'Bold',
  48. 'extrabold' => 'ExtraBold',
  49. 'heavy' => 'ExtraBold',
  50. 'black' => 'Black'
  51. }
  52. # Handle font attribute - can be weight name or custom font family
  53. 44 if json_data['font']
  54. 4 font_value = json_data['font'].to_s.downcase
  55. 4 if weight_names.include?(font_value)
  56. # It's a weight name, use FontWeight
  57. 3 weight = weight_mapping[font_value] || 'Normal'
  58. 3 required_imports&.add(:font_weight)
  59. 3 component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
  60. else
  61. # It's a custom font family name
  62. 1 required_imports&.add(:font_family)
  63. 1 component_code += "\n" + indent("fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase})),", depth + 1)
  64. end
  65. 40 elsif json_data['fontWeight']
  66. # fontWeight attribute takes precedence if font not specified
  67. 7 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  68. 7 required_imports&.add(:font_weight)
  69. 7 component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
  70. end
  71. # Text decoration (underline, strikethrough)
  72. 44 text_decorations = []
  73. 44 if json_data['underline']
  74. 2 required_imports&.add(:text_decoration)
  75. 2 text_decorations << "TextDecoration.Underline"
  76. end
  77. 44 if json_data['strikethrough']
  78. 2 required_imports&.add(:text_decoration)
  79. 2 text_decorations << "TextDecoration.LineThrough"
  80. end
  81. 44 if text_decorations.any?
  82. 3 if text_decorations.length > 1
  83. 1 component_code += "\n" + indent("textDecoration = TextDecoration.combine(listOf(#{text_decorations.join(', ')})),", depth + 1)
  84. else
  85. 2 component_code += "\n" + indent("textDecoration = #{text_decorations.first},", depth + 1)
  86. end
  87. end
  88. # Text shadow and line height
  89. 44 style_parts = []
  90. 44 if json_data['textShadow']
  91. 1 required_imports&.add(:shadow_style)
  92. 1 style_parts << "shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f)"
  93. end
  94. 44 if json_data['lineHeightMultiple']
  95. 1 required_imports&.add(:text_style)
  96. # Line height multiplier - apply to font size
  97. 1 line_height = json_data['fontSize'] ? json_data['fontSize'].to_f * json_data['lineHeightMultiple'].to_f : 14.0 * json_data['lineHeightMultiple'].to_f
  98. 1 style_parts << "lineHeight = #{line_height}.sp"
  99. 43 elsif json_data['lineSpacing']
  100. required_imports&.add(:text_style)
  101. # Line spacing - add to base font size
  102. base_size = json_data['fontSize'] ? json_data['fontSize'].to_f : 14.0
  103. line_height = base_size + json_data['lineSpacing'].to_f
  104. style_parts << "lineHeight = #{line_height}.sp"
  105. end
  106. 44 if style_parts.any?
  107. 2 required_imports&.add(:text_style)
  108. 2 component_code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  109. end
  110. # Build modifiers
  111. 44 modifiers = []
  112. # Get visibility info (but don't add to modifiers, will be handled by wrapper)
  113. 44 visibility_result = Helpers::ModifierBuilder.build_visibility(json_data, required_imports)
  114. 44 modifiers.concat(visibility_result[:modifiers]) if visibility_result[:modifiers].any?
  115. 44 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  116. # Add weight modifier if in Row or Column
  117. 44 if parent_type == 'Row' || parent_type == 'Column'
  118. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  119. end
  120. # 1. Add size first (total size including padding)
  121. 44 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  122. # 2. Add margins (outside spacing)
  123. 44 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  124. # 3. Add shadow before background
  125. 44 modifiers.concat(Helpers::ModifierBuilder.build_shadow(json_data, required_imports))
  126. # 4. Add background before padding (so padding creates space inside the background)
  127. 44 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  128. # 5. Handle edgeInset for text-specific padding (inside spacing) - applied last
  129. 44 if json_data['edgeInset']
  130. 2 insets = json_data['edgeInset']
  131. 2 if insets.is_a?(Array) && insets.length == 4
  132. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  133. 1 elsif insets.is_a?(Numeric)
  134. 1 modifiers << ".padding(#{insets}.dp)"
  135. end
  136. else
  137. 42 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  138. end
  139. # Format modifiers
  140. 44 if modifiers.any?
  141. 8 component_code += Helpers::ModifierBuilder.format(modifiers, depth)
  142. else
  143. 36 component_code += "\n" + indent("modifier = Modifier", depth + 1)
  144. end
  145. # Text alignment
  146. 44 if json_data['textAlign']
  147. 3 required_imports&.add(:text_align)
  148. 3 case json_data['textAlign'].downcase
  149. when 'center'
  150. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  151. when 'right'
  152. 1 component_code += ",\n" + indent("textAlign = TextAlign.End", depth + 1)
  153. when 'left'
  154. 1 component_code += ",\n" + indent("textAlign = TextAlign.Start", depth + 1)
  155. end
  156. 41 elsif json_data['centerHorizontal']
  157. 1 required_imports&.add(:text_align)
  158. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  159. end
  160. # Lines (maxLines)
  161. 44 if json_data['lines']
  162. 2 if json_data['lines'] == 0
  163. 1 component_code += ",\n" + indent("maxLines = Int.MAX_VALUE", depth + 1)
  164. else
  165. 1 component_code += ",\n" + indent("maxLines = #{json_data['lines']}", depth + 1)
  166. end
  167. end
  168. # Auto shrink text
  169. 44 if json_data['autoShrink']
  170. required_imports&.add(:text_overflow)
  171. component_code += ",\n" + indent("softWrap = false", depth + 1)
  172. component_code += ",\n" + indent("maxLines = 1", depth + 1)
  173. component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  174. end
  175. # Minimum scale factor (auto-shrink text)
  176. # In Compose, this is achieved with softWrap=false and overflow=Visible to allow text to scale
  177. 44 if json_data['minimumScaleFactor']
  178. # Note: Compose doesn't have direct equivalent, but we can use single line with ellipsis
  179. # or recommend using a custom composable. For now, we'll add a comment
  180. 1 component_code += ",\n" + indent("// minimumScaleFactor: #{json_data['minimumScaleFactor']} - Consider using AutoSizeText library", depth + 1)
  181. 1 component_code += ",\n" + indent("maxLines = 1", depth + 1)
  182. 1 required_imports&.add(:text_overflow)
  183. 1 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  184. end
  185. # Line break mode (overflow)
  186. 44 if json_data['lineBreakMode']
  187. 3 required_imports&.add(:text_overflow)
  188. 3 case json_data['lineBreakMode'].downcase
  189. when 'clip'
  190. 1 component_code += ",\n" + indent("overflow = TextOverflow.Clip", depth + 1)
  191. when 'tail', 'word'
  192. 2 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  193. end
  194. end
  195. # highlightColor - color when pressed/selected
  196. 44 if json_data['highlightColor']
  197. highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightColor'], required_imports)
  198. component_code += ",\n" + indent("// highlightColor: #{highlight_color} - Use InteractionSource for pressed state styling", depth + 1)
  199. end
  200. 44 component_code += "\n" + indent(")", depth)
  201. # Wrap with VisibilityWrapper if needed
  202. 44 Helpers::VisibilityHelper.wrap_with_visibility(json_data, component_code, depth, required_imports)
  203. end
  204. 1 private
  205. 1 def self.generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  206. 9 required_imports&.add(:partial_attributes_text)
  207. 9 text = json_data['text'] || ''
  208. 9 code = indent("PartialAttributesText(", depth)
  209. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  210. 9 code += "\n" + indent("linkable = true,", depth + 1)
  211. # Build style
  212. 9 style_parts = []
  213. 9 if json_data['fontSize']
  214. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  215. end
  216. 9 if json_data['fontColor']
  217. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  218. 1 style_parts << "color = #{color_value}" if color_value
  219. end
  220. # Handle font attribute for linkable text style
  221. 9 font_weight_result = resolve_font_attribute(json_data, required_imports)
  222. 9 style_parts << font_weight_result if font_weight_result
  223. 9 if json_data['textAlign']
  224. 3 required_imports&.add(:text_align)
  225. 3 case json_data['textAlign'].downcase
  226. when 'center'
  227. 1 style_parts << "textAlign = TextAlign.Center"
  228. when 'right'
  229. 1 style_parts << "textAlign = TextAlign.End"
  230. when 'left'
  231. 1 style_parts << "textAlign = TextAlign.Start"
  232. end
  233. end
  234. 9 if style_parts.any?
  235. 6 required_imports&.add(:text_style)
  236. 6 code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  237. end
  238. # Build modifiers
  239. 9 modifiers = []
  240. 9 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  241. 9 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  242. # Handle edgeInset for text-specific padding
  243. 9 if json_data['edgeInset']
  244. 2 insets = json_data['edgeInset']
  245. 2 if insets.is_a?(Array) && insets.length == 4
  246. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  247. 1 elsif insets.is_a?(Numeric)
  248. 1 modifiers << ".padding(#{insets}.dp)"
  249. end
  250. else
  251. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  252. end
  253. # Add background
  254. 9 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  255. 9 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  256. 9 if modifiers.any?
  257. 2 code += Helpers::ModifierBuilder.format(modifiers, depth)
  258. else
  259. 7 code += "\n" + indent("modifier = Modifier", depth + 1)
  260. end
  261. 9 code += "\n" + indent(")", depth)
  262. # Wrap with VisibilityWrapper if needed
  263. 9 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  264. end
  265. 1 def self.generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  266. 8 required_imports&.add(:partial_attributes_text)
  267. 8 text = json_data['text'] || ''
  268. 8 partial_attrs = json_data['partialAttributes']
  269. 8 code = indent("PartialAttributesText(", depth)
  270. 8 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  271. # Build partial attributes list
  272. 8 code += "\n" + indent("partialAttributes = listOf(", depth + 1)
  273. 8 partial_attrs.each_with_index do |attr, index|
  274. 9 code += "\n" + indent("PartialAttribute.fromJsonRange(", depth + 2)
  275. # Handle range - can be array or string
  276. 9 range = attr['range']
  277. 9 if range.is_a?(Array)
  278. 8 code += "\n" + indent("range = listOf(#{range.join(', ')}),", depth + 3)
  279. 1 elsif range.is_a?(String)
  280. 1 code += "\n" + indent("range = \"#{escape_string(range)}\",", depth + 3)
  281. end
  282. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 3)
  283. # Add optional attributes
  284. 9 if attr['fontColor']
  285. 4 code += "\n" + indent("fontColor = \"#{attr['fontColor']}\",", depth + 3)
  286. end
  287. 9 if attr['fontSize']
  288. 1 code += "\n" + indent("fontSize = #{attr['fontSize']},", depth + 3)
  289. end
  290. 9 if attr['fontWeight']
  291. 1 code += "\n" + indent("fontWeight = \"#{attr['fontWeight']}\",", depth + 3)
  292. end
  293. 9 if attr['background']
  294. 1 code += "\n" + indent("background = \"#{attr['background']}\",", depth + 3)
  295. end
  296. 9 if attr['underline']
  297. 1 code += "\n" + indent("underline = #{attr['underline']},", depth + 3)
  298. end
  299. 9 if attr['strikethrough']
  300. 1 code += "\n" + indent("strikethrough = #{attr['strikethrough']},", depth + 3)
  301. end
  302. # Handle click events for partial attributes
  303. # onclick (lowercase) -> selector format (string only)
  304. # onClick (camelCase) -> binding format only (@{functionName})
  305. 9 if attr['onclick']
  306. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onclick'], is_camel_case: false)
  307. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
  308. 8 elsif attr['onClick']
  309. handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onClick'], is_camel_case: true)
  310. code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
  311. else
  312. 8 code += "\n" + indent("onClick = null", depth + 3)
  313. end
  314. 9 code += "\n" + indent(")!!", depth + 2) # !! because fromJsonRange returns nullable
  315. 9 code += "," if index < partial_attrs.length - 1
  316. end
  317. 8 code += "\n" + indent("),", depth + 1)
  318. # Build modifiers
  319. 8 modifiers = []
  320. 8 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  321. 8 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  322. 8 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  323. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  324. 8 if modifiers.any?
  325. code += Helpers::ModifierBuilder.format(modifiers, depth)
  326. else
  327. 8 code += "\n" + indent("modifier = Modifier", depth + 1)
  328. end
  329. # Add style
  330. 8 style_parts = []
  331. 8 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  332. 8 if json_data['fontColor']
  333. color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  334. style_parts << "color = #{color_value}" if color_value
  335. end
  336. 8 if json_data['textAlign']
  337. required_imports&.add(:text_align)
  338. case json_data['textAlign'].downcase
  339. when 'center'
  340. style_parts << "textAlign = TextAlign.Center"
  341. when 'right'
  342. style_parts << "textAlign = TextAlign.End"
  343. when 'left'
  344. style_parts << "textAlign = TextAlign.Start"
  345. end
  346. end
  347. 8 if style_parts.any?
  348. required_imports&.add(:text_style)
  349. code += ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth + 1)
  350. end
  351. 8 code += "\n" + indent(")", depth)
  352. # Wrap with VisibilityWrapper if needed
  353. 8 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  354. end
  355. 1 def self.generate_with_partial_attributes(json_data, depth, required_imports, parent_type)
  356. required_imports&.add(:annotated_string)
  357. required_imports&.add(:clickable_text)
  358. required_imports&.add(:remember_state)
  359. text = json_data['text'] || ''
  360. partial_attrs = json_data['partialAttributes']
  361. # Build AnnotatedString as a variable first
  362. code = indent("val annotatedText = buildAnnotatedString {", depth)
  363. code += "\n" + indent("append(\"#{escape_string(text)}\")", depth + 1)
  364. # Apply partial attributes
  365. partial_attrs.each do |attr|
  366. range = attr['range']
  367. next unless range && range.is_a?(Array) && range.length == 2
  368. start_idx = range[0]
  369. end_idx = range[1]
  370. # Build SpanStyle for this range
  371. span_styles = []
  372. if attr['fontColor']
  373. color_resolved = Helpers::ResourceResolver.process_color(attr['fontColor'], required_imports)
  374. span_styles << "color = #{color_resolved}"
  375. end
  376. if attr['fontSize']
  377. span_styles << "fontSize = #{attr['fontSize']}.sp"
  378. end
  379. if attr['fontWeight']
  380. weight_mapping = {
  381. 'bold' => 'Bold',
  382. 'semibold' => 'SemiBold',
  383. 'medium' => 'Medium',
  384. 'light' => 'Light'
  385. }
  386. weight = weight_mapping[attr['fontWeight'].downcase] || 'Normal'
  387. span_styles << "fontWeight = FontWeight.#{weight}"
  388. end
  389. if attr['background']
  390. background_resolved = Helpers::ResourceResolver.process_color(attr['background'], required_imports)
  391. span_styles << "background = #{background_resolved}"
  392. end
  393. if attr['underline']
  394. required_imports&.add(:text_decoration)
  395. span_styles << "textDecoration = TextDecoration.Underline"
  396. end
  397. if attr['strikethrough']
  398. required_imports&.add(:text_decoration)
  399. span_styles << "textDecoration = TextDecoration.LineThrough"
  400. end
  401. if span_styles.any?
  402. code += "\n" + indent("addStyle(", depth + 1)
  403. code += "\n" + indent("style = SpanStyle(#{span_styles.join(', ')}),", depth + 2)
  404. code += "\n" + indent("start = #{start_idx},", depth + 2)
  405. code += "\n" + indent("end = #{end_idx}", depth + 2)
  406. code += "\n" + indent(")", depth + 1)
  407. end
  408. # Add clickable annotation if onclick/onClick is specified
  409. click_handler = attr['onclick'] || attr['onClick']
  410. if click_handler
  411. # Extract method name from binding format if needed
  412. method_name = if click_handler.match?(/^@\{(.+)\}$/)
  413. click_handler.match(/^@\{(.+)\}$/)[1]
  414. else
  415. click_handler.gsub(':', '')
  416. end
  417. code += "\n" + indent("addStringAnnotation(", depth + 1)
  418. code += "\n" + indent("tag = \"CLICKABLE\",", depth + 2)
  419. code += "\n" + indent("annotation = \"#{method_name}\",", depth + 2)
  420. code += "\n" + indent("start = #{start_idx},", depth + 2)
  421. code += "\n" + indent("end = #{end_idx}", depth + 2)
  422. code += "\n" + indent(")", depth + 1)
  423. end
  424. end
  425. code += "\n" + indent("}", depth)
  426. code += "\n"
  427. # Now use ClickableText with the annotatedString
  428. code += indent("ClickableText(", depth)
  429. code += "\n" + indent("text = annotatedText,", depth + 1)
  430. # Add onClick handler for clickable ranges
  431. if partial_attrs.any? { |attr| attr['onclick'] }
  432. code += "\n" + indent("onClick = { offset ->", depth + 1)
  433. code += "\n" + indent("annotatedText.getStringAnnotations(\"CLICKABLE\", offset, offset)", depth + 2)
  434. code += "\n" + indent(".firstOrNull()?.let { annotation ->", depth + 3)
  435. code += "\n" + indent("viewModel.handlePartialClick(annotation.item)", depth + 4)
  436. code += "\n" + indent("}", depth + 3)
  437. code += "\n" + indent("},", depth + 1)
  438. else
  439. code += "\n" + indent("onClick = { },", depth + 1)
  440. end
  441. # Add style (fontSize, color, etc. for the whole text)
  442. style_code = build_text_style(json_data, depth + 1, required_imports)
  443. if style_code
  444. code += style_code
  445. end
  446. # Build modifiers
  447. modifiers = []
  448. modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  449. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  450. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  451. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  452. if modifiers.any?
  453. code += Helpers::ModifierBuilder.format(modifiers, depth)
  454. else
  455. code += "\n" + indent("modifier = Modifier", depth + 1)
  456. end
  457. code += "\n" + indent(")", depth)
  458. # Wrap with VisibilityWrapper if needed
  459. Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  460. end
  461. 1 def self.build_text_style(json_data, depth, required_imports)
  462. 4 style_parts = []
  463. 4 if json_data['fontSize']
  464. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  465. end
  466. 4 if json_data['fontColor']
  467. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  468. 1 style_parts << "color = #{color_value}" if color_value
  469. end
  470. 4 if json_data['textAlign']
  471. 1 required_imports&.add(:text_align)
  472. 1 case json_data['textAlign'].downcase
  473. when 'center'
  474. 1 style_parts << "textAlign = TextAlign.Center"
  475. when 'right'
  476. style_parts << "textAlign = TextAlign.End"
  477. when 'left'
  478. style_parts << "textAlign = TextAlign.Start"
  479. end
  480. end
  481. 4 if style_parts.any?
  482. 3 required_imports&.add(:text_style)
  483. 3 return ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth)
  484. end
  485. nil
  486. end
  487. # Resolve font attribute - returns style string for fontWeight or fontFamily
  488. 1 def self.resolve_font_attribute(json_data, required_imports)
  489. 9 weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
  490. 9 weight_mapping = {
  491. 'thin' => 'Thin',
  492. 'extralight' => 'ExtraLight',
  493. 'light' => 'Light',
  494. 'normal' => 'Normal',
  495. 'medium' => 'Medium',
  496. 'semibold' => 'SemiBold',
  497. 'bold' => 'Bold',
  498. 'extrabold' => 'ExtraBold',
  499. 'heavy' => 'ExtraBold',
  500. 'black' => 'Black'
  501. }
  502. 9 if json_data['font']
  503. font_value = json_data['font'].to_s.downcase
  504. if weight_names.include?(font_value)
  505. weight = weight_mapping[font_value] || 'Normal'
  506. required_imports&.add(:font_weight)
  507. "fontWeight = FontWeight.#{weight}"
  508. else
  509. required_imports&.add(:font_family)
  510. "fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase}))"
  511. end
  512. 9 elsif json_data['fontWeight']
  513. 1 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  514. 1 required_imports&.add(:font_weight)
  515. 1 "fontWeight = FontWeight.#{weight}"
  516. end
  517. end
  518. 1 def self.escape_string(text)
  519. 32 text.gsub('\\', '\\\\\\\\')
  520. .gsub('"', '\\"')
  521. .gsub("\n", '\\n')
  522. .gsub("\r", '\\r')
  523. .gsub("\t", '\\t')
  524. end
  525. 1 def self.quote(text)
  526. # Escape special characters properly
  527. 2 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  528. .gsub('"', '\\"') # Escape quotes
  529. .gsub("\n", '\\n') # Escape newlines
  530. .gsub("\r", '\\r') # Escape carriage returns
  531. .gsub("\t", '\\t') # Escape tabs
  532. 2 "\"#{escaped}\""
  533. end
  534. 1 def self.indent(text, level)
  535. 356 return text if level == 0
  536. 236 spaces = ' ' * level
  537. 236 text.split("\n").map { |line|
  538. 238 line.empty? ? line : spaces + line
  539. }.join("\n")
  540. end
  541. end
  542. end
  543. end
  544. end

lib/compose/components/textfield_component.rb

78.5% lines covered

200 relevant lines. 157 lines covered and 43 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextFieldComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextField uses 'text' for value and supports both 'hint' and 'placeholder'
  10. # For TextField value, we need direct data binding (not string interpolation)
  11. 40 raw_text = json_data['text'] || ''
  12. 40 value = if raw_text.match(/@\{([^}]+)\}/)
  13. 3 variable = $1
  14. 3 var_name = variable.include?(' ?? ') ? variable.split(' ?? ')[0].strip : variable
  15. 3 "data.#{var_name}"
  16. else
  17. 37 Helpers::ResourceResolver.process_text(raw_text, required_imports)
  18. end
  19. 40 placeholder_text = json_data['hint'] || json_data['placeholder'] || ''
  20. 40 placeholder = placeholder_text.empty? ? '""' : Helpers::ResourceResolver.process_text(placeholder_text, required_imports)
  21. 40 is_secure = json_data['secure'] == true
  22. # Check if we need to wrap in Box for margins
  23. 40 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  24. json_data['leftMargin'] || json_data['rightMargin']
  25. # Always use CustomTextField
  26. 40 required_imports&.add(:custom_textfield)
  27. 40 required_imports&.add(:visual_transformation) if is_secure
  28. 40 code = ""
  29. 40 if has_margins
  30. 2 required_imports&.add(:box)
  31. 2 code = indent("CustomTextFieldWithMargins(", depth)
  32. else
  33. 38 code = indent("CustomTextField(", depth)
  34. end
  35. 40 code += "\n" + indent("value = #{value},", depth + 1)
  36. # Handle onValueChange/onTextChange
  37. # Data binding: update via viewModel.updateData to trigger StateFlow, then call onTextChange callback if specified
  38. 40 if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  39. 3 variable = extract_variable_name(json_data['text'])
  40. 3 if json_data['onTextChange']
  41. # Data binding + explicit callback
  42. # Strip @{} binding syntax from callback name
  43. callback_name = extract_binding_name(json_data['onTextChange'])
  44. code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)); data.#{callback_name}?.invoke() },", depth + 1)
  45. else
  46. # Data binding only
  47. 3 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  48. end
  49. 37 elsif json_data['onTextChange']
  50. # Explicit callback only (no data binding)
  51. # Strip @{} binding syntax from callback name
  52. 2 callback_name = extract_binding_name(json_data['onTextChange'])
  53. 2 code += "\n" + indent("onValueChange = { newValue -> data.#{callback_name}?.invoke() },", depth + 1)
  54. else
  55. 35 code += "\n" + indent("onValueChange = { },", depth + 1)
  56. end
  57. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  58. 40 if has_margins
  59. # Box modifier with margins
  60. 2 box_modifiers = []
  61. 2 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  62. 2 if box_modifiers.any?
  63. 2 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  64. 2 box_modifiers.each do |mod|
  65. 2 code += "\n" + indent(" #{mod}", depth + 1)
  66. end
  67. 2 code += ","
  68. end
  69. # TextField modifier (size only, padding goes to contentPadding)
  70. 2 textfield_modifiers = []
  71. 2 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  72. 2 if textfield_modifiers.any?
  73. code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  74. textfield_modifiers.each do |mod|
  75. code += "\n" + indent(" #{mod}", depth + 1)
  76. end
  77. code += ","
  78. end
  79. else
  80. # Regular modifiers for CustomTextField (size and margins only, padding goes to contentPadding)
  81. 38 modifiers = []
  82. 38 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  83. 38 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  84. 38 if modifiers.any?
  85. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  86. 1 modifiers.each do |mod|
  87. 2 code += "\n" + indent(" #{mod}", depth + 1)
  88. end
  89. 1 code += ","
  90. end
  91. end
  92. # Add placeholder/hint with styling
  93. # Always use Configuration.TextField.defaultPlaceholderColor if hintColor is not specified
  94. 40 if placeholder && placeholder != '""'
  95. 3 required_imports&.add(:configuration)
  96. 3 placeholder_code = "placeholder = { Text("
  97. 3 placeholder_code += "\n" + indent("text = #{placeholder}", depth + 2)
  98. # Use hintColor if specified, otherwise use Configuration default
  99. 3 if json_data['hintColor']
  100. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  101. 1 placeholder_code += ",\n" + indent("color = #{hint_color}", depth + 2)
  102. else
  103. 2 placeholder_code += ",\n" + indent("color = Configuration.TextField.defaultPlaceholderColor", depth + 2)
  104. end
  105. 3 if json_data['hintFontSize']
  106. 1 placeholder_code += ",\n" + indent("fontSize = #{json_data['hintFontSize']}.sp", depth + 2)
  107. end
  108. 3 if json_data['hintFont'] == 'bold'
  109. 1 placeholder_code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 2)
  110. end
  111. 3 placeholder_code += "\n" + indent(") }", depth + 1)
  112. 3 code += "\n" + indent(placeholder_code, depth + 1) + ","
  113. end
  114. # Add visual transformation for secure fields
  115. 40 if is_secure
  116. 1 code += "\n" + indent("visualTransformation = PasswordVisualTransformation(),", depth + 1)
  117. end
  118. # Add custom TextField parameters
  119. # Shape with corner radius
  120. 40 if json_data['cornerRadius']
  121. 1 required_imports&.add(:shape)
  122. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  123. end
  124. # Content padding - internal padding within the text field
  125. # Supports: paddings (array or single value), fieldPadding (legacy single value)
  126. 40 if json_data['paddings']
  127. required_imports&.add(:padding_values)
  128. paddings = json_data['paddings']
  129. if paddings.is_a?(Array)
  130. case paddings.length
  131. when 1
  132. code += "\n" + indent("contentPadding = PaddingValues(#{paddings[0]}.dp),", depth + 1)
  133. when 2
  134. # [vertical, horizontal]
  135. code += "\n" + indent("contentPadding = PaddingValues(horizontal = #{paddings[1]}.dp, vertical = #{paddings[0]}.dp),", depth + 1)
  136. when 4
  137. # [top, right, bottom, left]
  138. code += "\n" + indent("contentPadding = PaddingValues(start = #{paddings[3]}.dp, top = #{paddings[0]}.dp, end = #{paddings[1]}.dp, bottom = #{paddings[2]}.dp),", depth + 1)
  139. end
  140. else
  141. code += "\n" + indent("contentPadding = PaddingValues(#{paddings}.dp),", depth + 1)
  142. end
  143. 40 elsif json_data['fieldPadding']
  144. required_imports&.add(:padding_values)
  145. code += "\n" + indent("contentPadding = PaddingValues(#{json_data['fieldPadding']}.dp),", depth + 1)
  146. end
  147. # Text padding left - start padding for text content
  148. 40 if json_data['textPaddingLeft']
  149. code += "\n" + indent("textPaddingStart = #{json_data['textPaddingLeft']}.dp,", depth + 1)
  150. end
  151. # Background colors
  152. 40 if json_data['background']
  153. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  154. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  155. end
  156. 40 if json_data['highlightBackground']
  157. 1 highlight_bg_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  158. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_bg_color},", depth + 1)
  159. end
  160. # Border color for outlined text fields
  161. 40 if json_data['borderColor']
  162. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  163. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  164. end
  165. # Border style handling
  166. # borderStyle: none, line, bezel, roundedRect
  167. 40 if json_data['borderStyle']
  168. case json_data['borderStyle'].downcase
  169. when 'none'
  170. code += "\n" + indent("isOutlined = false,", depth + 1)
  171. when 'line', 'bezel', 'roundedrect'
  172. code += "\n" + indent("isOutlined = true,", depth + 1)
  173. end
  174. # Set isOutlined and isSecure flags
  175. # Automatically use outlined style if borderColor or borderWidth is specified
  176. 40 elsif json_data['outlined'] == true || json_data['borderColor'] || json_data['borderWidth']
  177. 2 code += "\n" + indent("isOutlined = true,", depth + 1)
  178. end
  179. 40 if is_secure
  180. 1 code += "\n" + indent("isSecure = true,", depth + 1)
  181. end
  182. # Text styling - always add this last before closing
  183. # Always include textStyle with at least a default color
  184. 40 required_imports&.add(:text_style)
  185. 40 style_parts = []
  186. 40 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  187. # Use fontColor if specified, otherwise default to black
  188. 40 if json_data['fontColor']
  189. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  190. 1 style_parts << "color = #{color_value}" if color_value
  191. else
  192. # Default to black text
  193. 39 default_color = Helpers::ResourceResolver.process_color('#000000', required_imports)
  194. 39 style_parts << "color = #{default_color}"
  195. end
  196. 40 if json_data['textAlign']
  197. 3 required_imports&.add(:text_align)
  198. 3 case json_data['textAlign'].downcase
  199. when 'center'
  200. 1 style_parts << "textAlign = TextAlign.Center"
  201. when 'right'
  202. 1 style_parts << "textAlign = TextAlign.End"
  203. when 'left'
  204. 1 style_parts << "textAlign = TextAlign.Start"
  205. end
  206. end
  207. 40 if style_parts.any?
  208. # Remove trailing comma before adding textStyle
  209. 40 if code.end_with?(',')
  210. 40 code = code[0..-2]
  211. end
  212. 40 code += ",\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  213. end
  214. # Add focus/blur event handlers
  215. 40 if json_data['onFocus']
  216. 1 code += ",\n" + indent("onFocus = { data.#{json_data['onFocus']}?.invoke() }", depth + 1)
  217. end
  218. 40 if json_data['onBlur']
  219. 1 code += ",\n" + indent("onBlur = { data.#{json_data['onBlur']}?.invoke() }", depth + 1)
  220. end
  221. 40 if json_data['onBeginEditing']
  222. 1 code += ",\n" + indent("onBeginEditing = { data.#{json_data['onBeginEditing']}?.invoke() }", depth + 1)
  223. end
  224. 40 if json_data['onEndEditing']
  225. 1 code += ",\n" + indent("onEndEditing = { data.#{json_data['onEndEditing']}?.invoke() }", depth + 1)
  226. end
  227. # Keyboard options (input, returnKeyType, contentType, autocapitalizationType, autocorrectionType)
  228. 40 keyboard_options = []
  229. # Input type / contentType - contentType takes priority
  230. 40 if json_data['contentType']
  231. required_imports&.add(:keyboard_type)
  232. keyboard_type = case json_data['contentType'].downcase
  233. when 'emailaddress', 'email'
  234. 'KeyboardType.Email'
  235. when 'password', 'newpassword'
  236. 'KeyboardType.Password'
  237. when 'telephonenumber', 'phone'
  238. 'KeyboardType.Phone'
  239. when 'url'
  240. 'KeyboardType.Uri'
  241. when 'creditcardnumber'
  242. 'KeyboardType.Number'
  243. else
  244. 'KeyboardType.Text'
  245. end
  246. keyboard_options << "keyboardType = #{keyboard_type}"
  247. 40 elsif json_data['input']
  248. 6 required_imports&.add(:keyboard_type)
  249. 6 keyboard_type = case json_data['input']
  250. when 'email'
  251. 1 'KeyboardType.Email'
  252. when 'password'
  253. 1 'KeyboardType.Password'
  254. when 'number'
  255. 1 'KeyboardType.Number'
  256. when 'decimal'
  257. 1 'KeyboardType.Decimal'
  258. when 'phone'
  259. 1 'KeyboardType.Phone'
  260. else
  261. 1 'KeyboardType.Text'
  262. end
  263. 6 keyboard_options << "keyboardType = #{keyboard_type}"
  264. end
  265. 40 if json_data['returnKeyType']
  266. 6 required_imports&.add(:ime_action)
  267. 6 ime_action = case json_data['returnKeyType']
  268. when 'Done'
  269. 1 'ImeAction.Done'
  270. when 'Next'
  271. 1 'ImeAction.Next'
  272. when 'Search'
  273. 1 'ImeAction.Search'
  274. when 'Send'
  275. 1 'ImeAction.Send'
  276. when 'Go'
  277. 1 'ImeAction.Go'
  278. else
  279. 1 'ImeAction.Default'
  280. end
  281. 6 keyboard_options << "imeAction = #{ime_action}"
  282. end
  283. # Auto-capitalization type
  284. 40 if json_data['autocapitalizationType']
  285. required_imports&.add(:keyboard_capitalization)
  286. capitalization = case json_data['autocapitalizationType'].downcase
  287. when 'none'
  288. 'KeyboardCapitalization.None'
  289. when 'words'
  290. 'KeyboardCapitalization.Words'
  291. when 'sentences'
  292. 'KeyboardCapitalization.Sentences'
  293. when 'allcharacters', 'characters'
  294. 'KeyboardCapitalization.Characters'
  295. else
  296. 'KeyboardCapitalization.None'
  297. end
  298. keyboard_options << "capitalization = #{capitalization}"
  299. end
  300. # Auto-correction type
  301. 40 if json_data['autocorrectionType']
  302. auto_correct = case json_data['autocorrectionType'].downcase
  303. when 'no', 'false', 'off'
  304. 'false'
  305. when 'yes', 'true', 'on', 'default'
  306. 'true'
  307. else
  308. 'true'
  309. end
  310. keyboard_options << "autoCorrect = #{auto_correct}"
  311. end
  312. 40 if keyboard_options.any?
  313. 12 code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
  314. end
  315. # Remove trailing comma and close
  316. 40 if code.end_with?(',')
  317. code = code[0..-2]
  318. end
  319. 40 code += "\n" + indent(")", depth)
  320. 40 code
  321. end
  322. 1 private
  323. 1 def self.extract_variable_name(text)
  324. 6 if text && text.match(/@\{([^}]+)\}/)
  325. 5 $1.split('.').last
  326. else
  327. 1 'value'
  328. end
  329. end
  330. # Strip @{} binding syntax from a value and return the property name
  331. 1 def self.extract_binding_name(value)
  332. 2 if value && value.match(/@\{([^}]+)\}/)
  333. 1 $1
  334. else
  335. 1 value
  336. end
  337. end
  338. 1 def self.indent(text, level)
  339. 247 return text if level == 0
  340. 166 spaces = ' ' * level
  341. 166 text.split("\n").map { |line|
  342. 177 line.empty? ? line : spaces + line
  343. }.join("\n")
  344. end
  345. end
  346. end
  347. end
  348. end

lib/compose/components/textview_component.rb

80.11% lines covered

181 relevant lines. 145 lines covered and 36 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextViewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextView is multi-line text input (like TextArea)
  10. # Uses 'text' for value and supports both 'hint' and 'placeholder' (hint is primary)
  11. 45 value = process_data_binding(json_data['text'] || '')
  12. 45 placeholder = json_data['hint'] || json_data['placeholder'] || ''
  13. # Check if we need to wrap in Box for margins
  14. 45 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  15. json_data['leftMargin'] || json_data['rightMargin']
  16. # Always use CustomTextField
  17. 45 required_imports&.add(:custom_textfield)
  18. 45 code = ""
  19. 45 if has_margins
  20. 11 required_imports&.add(:box)
  21. 11 code = indent("CustomTextFieldWithMargins(", depth)
  22. else
  23. 34 code = indent("CustomTextField(", depth)
  24. end
  25. 45 code += "\n" + indent("value = #{value},", depth + 1)
  26. # onValueChange handler
  27. # Data binding: directly update data property, then call onTextChange callback if specified
  28. 45 if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  29. 2 variable = extract_variable_name(json_data['text'])
  30. 2 if json_data['onTextChange']
  31. # Data binding + explicit callback
  32. code += "\n" + indent("onValueChange = { newValue -> data.#{variable} = newValue; data.#{json_data['onTextChange']}?.invoke() },", depth + 1)
  33. else
  34. # Data binding only
  35. 2 code += "\n" + indent("onValueChange = { newValue -> data.#{variable} = newValue },", depth + 1)
  36. end
  37. 43 elsif json_data['onTextChange']
  38. # Explicit callback only (no data binding)
  39. 1 code += "\n" + indent("onValueChange = { newValue -> data.#{json_data['onTextChange']}?.invoke() },", depth + 1)
  40. else
  41. 42 code += "\n" + indent("onValueChange = { },", depth + 1)
  42. end
  43. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  44. 45 if has_margins
  45. # Box modifier with margins
  46. 11 box_modifiers = []
  47. 11 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  48. 11 if box_modifiers.any?
  49. 11 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  50. 11 box_modifiers.each do |mod|
  51. 11 code += "\n" + indent(" #{mod}", depth + 1)
  52. end
  53. 11 code += ","
  54. end
  55. # TextField modifier
  56. 11 textfield_modifiers = []
  57. # Size - default to fillMaxWidth for text areas
  58. 11 if json_data['width'] == 'matchParent' || !json_data['width']
  59. 10 textfield_modifiers << ".fillMaxWidth()"
  60. else
  61. 1 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  62. end
  63. # Height for multi-line
  64. 11 if json_data['height']
  65. 3 if json_data['height'] == 'matchParent'
  66. 1 textfield_modifiers << ".fillMaxHeight()"
  67. 2 elsif json_data['height'] == 'wrapContent'
  68. 1 textfield_modifiers << ".wrapContentHeight()"
  69. else
  70. 1 textfield_modifiers << ".height(#{json_data['height']}.dp)"
  71. end
  72. else
  73. # Default height for text area
  74. 8 textfield_modifiers << ".height(120.dp)"
  75. end
  76. 11 textfield_modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  77. 11 if textfield_modifiers.any?
  78. 11 code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  79. 11 textfield_modifiers.each do |mod|
  80. 22 code += "\n" + indent(" #{mod}", depth + 1)
  81. end
  82. 11 code += ","
  83. end
  84. else
  85. # Regular modifiers for CustomTextField
  86. 34 modifiers = []
  87. # Size - default to fillMaxWidth for text areas
  88. 34 if json_data['width'] == 'matchParent' || !json_data['width']
  89. 33 modifiers << ".fillMaxWidth()"
  90. else
  91. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  92. end
  93. # Height for multi-line
  94. 34 if json_data['height']
  95. 3 if json_data['height'] == 'matchParent'
  96. 1 modifiers << ".fillMaxHeight()"
  97. 2 elsif json_data['height'] == 'wrapContent'
  98. 1 modifiers << ".wrapContentHeight()"
  99. else
  100. 1 modifiers << ".height(#{json_data['height']}.dp)"
  101. end
  102. else
  103. # Default height for text area
  104. 31 modifiers << ".height(120.dp)"
  105. end
  106. 34 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  107. 34 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  108. 34 if modifiers.any?
  109. 34 code += "\n" + indent("modifier = Modifier", depth + 1)
  110. 34 modifiers.each do |mod|
  111. 68 code += "\n" + indent(" #{mod}", depth + 1)
  112. end
  113. 34 code += ","
  114. end
  115. end
  116. # Placeholder with optional line height styling
  117. 45 if placeholder && !placeholder.empty?
  118. 2 if json_data['hintLineHeightMultiple']
  119. # Complex placeholder with line height
  120. required_imports&.add(:text_style)
  121. base_size = json_data['hintFontSize'] || json_data['fontSize'] || 14
  122. line_height = base_size.to_f * json_data['hintLineHeightMultiple'].to_f
  123. code += "\n" + indent("placeholder = {", depth + 1)
  124. code += "\n" + indent("Text(", depth + 2)
  125. code += "\n" + indent("text = #{quote(placeholder)},", depth + 3)
  126. code += "\n" + indent("style = TextStyle(lineHeight = #{line_height}.sp)", depth + 3)
  127. code += "\n" + indent(")", depth + 2)
  128. code += "\n" + indent("},", depth + 1)
  129. else
  130. 2 code += "\n" + indent("placeholder = { Text(#{quote(placeholder)}) },", depth + 1)
  131. end
  132. end
  133. # Container inset - internal padding
  134. 45 if json_data['containerInset']
  135. inset = json_data['containerInset']
  136. if inset.is_a?(Array) && inset.length == 4
  137. code += "\n" + indent("contentPadding = PaddingValues(top = #{inset[0]}.dp, end = #{inset[1]}.dp, bottom = #{inset[2]}.dp, start = #{inset[3]}.dp),", depth + 1)
  138. elsif inset.is_a?(Numeric)
  139. code += "\n" + indent("contentPadding = PaddingValues(#{inset}.dp),", depth + 1)
  140. end
  141. end
  142. # Flexible height - auto-expand based on content
  143. 45 if json_data['flexible']
  144. code += "\n" + indent("// flexible: true - height adjusts to content", depth + 1)
  145. end
  146. # Shape with corner radius
  147. 45 if json_data['cornerRadius']
  148. 1 required_imports&.add(:shape)
  149. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  150. end
  151. # Background colors
  152. 45 if json_data['background']
  153. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  154. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  155. end
  156. 45 if json_data['highlightBackground']
  157. 1 highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  158. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_color},", depth + 1)
  159. end
  160. # Border color for outlined text fields
  161. 45 if json_data['borderColor']
  162. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  163. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  164. end
  165. # Set isOutlined flag (TextView usually wants outlined style)
  166. 45 code += "\n" + indent("isOutlined = true,", depth + 1)
  167. # Max lines for TextView
  168. 45 if json_data['maxLines']
  169. 1 code += "\n" + indent("maxLines = #{json_data['maxLines']},", depth + 1)
  170. else
  171. # Default to multiple lines
  172. 44 code += "\n" + indent("maxLines = Int.MAX_VALUE,", depth + 1)
  173. end
  174. # Single line false for multi-line
  175. 45 code += "\n" + indent("singleLine = false,", depth + 1)
  176. # Line break mode (overflow handling)
  177. 45 if json_data['lineBreakMode']
  178. # Note: For multi-line TextField, overflow is less relevant
  179. # but we include it for completeness
  180. case json_data['lineBreakMode'].to_s.downcase
  181. when 'clip'
  182. code += "\n" + indent("// lineBreakMode: clip", depth + 1)
  183. when 'tail', 'truncatetail'
  184. code += "\n" + indent("// lineBreakMode: truncate tail", depth + 1)
  185. when 'head', 'truncatehead'
  186. code += "\n" + indent("// lineBreakMode: truncate head", depth + 1)
  187. when 'middle', 'truncatemiddle'
  188. code += "\n" + indent("// lineBreakMode: truncate middle", depth + 1)
  189. when 'wordwrap', 'word'
  190. code += "\n" + indent("// lineBreakMode: word wrap (default)", depth + 1)
  191. when 'charwrap', 'char'
  192. code += "\n" + indent("// lineBreakMode: character wrap", depth + 1)
  193. end
  194. end
  195. # Text styling
  196. 45 if json_data['fontSize'] || json_data['fontColor']
  197. 3 required_imports&.add(:text_style)
  198. 3 style_parts = []
  199. 3 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  200. 3 if json_data['fontColor']
  201. 2 font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  202. 2 style_parts << "color = #{font_color}"
  203. end
  204. 3 if style_parts.any?
  205. 3 code += "\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  206. end
  207. end
  208. # Keyboard options
  209. 45 keyboard_options = []
  210. # keyboardType
  211. 45 if json_data['keyboardType'] || json_data['input']
  212. required_imports&.add(:keyboard_type)
  213. input_type = json_data['keyboardType'] || json_data['input']
  214. keyboard_type = case input_type.to_s.downcase
  215. when 'email'
  216. 'KeyboardType.Email'
  217. when 'number'
  218. 'KeyboardType.Number'
  219. when 'decimal'
  220. 'KeyboardType.Decimal'
  221. when 'phone'
  222. 'KeyboardType.Phone'
  223. when 'url'
  224. 'KeyboardType.Uri'
  225. else
  226. 'KeyboardType.Text'
  227. end
  228. keyboard_options << "keyboardType = #{keyboard_type}"
  229. end
  230. 45 if json_data['returnKeyType']
  231. 4 required_imports&.add(:ime_action)
  232. 4 ime_action = case json_data['returnKeyType']
  233. when 'Done'
  234. 1 'ImeAction.Done'
  235. when 'Next'
  236. 1 'ImeAction.Next'
  237. when 'Default'
  238. 1 'ImeAction.Default'
  239. else
  240. 1 'ImeAction.Default'
  241. end
  242. 4 keyboard_options << "imeAction = #{ime_action}"
  243. end
  244. 45 if keyboard_options.any?
  245. 4 code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
  246. end
  247. # scrollEnabled - controls vertical scroll within TextView
  248. 45 if json_data.key?('scrollEnabled')
  249. # In Compose, scrolling is controlled via verticalScroll modifier
  250. # For TextField, we just note it - actual implementation may need custom handling
  251. if json_data['scrollEnabled'] == false
  252. code += ",\n" + indent("// scrollEnabled = false - scrolling disabled", depth + 1)
  253. end
  254. end
  255. # hideOnFocused - hide placeholder when focused
  256. # Note: Compose TextField hides placeholder by default when there's text
  257. # This is primarily for when you want different behavior
  258. 45 if json_data.key?('hideOnFocused')
  259. code += ",\n" + indent("// hideOnFocused = #{json_data['hideOnFocused']}", depth + 1)
  260. end
  261. # Enabled state
  262. 45 if json_data.key?('enabled')
  263. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  264. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  265. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  266. else
  267. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  268. end
  269. end
  270. # Remove trailing comma and close
  271. 45 if code.end_with?(',')
  272. 35 code = code[0..-2]
  273. end
  274. 45 code += "\n" + indent(")", depth)
  275. 45 code
  276. end
  277. 1 private
  278. 1 def self.process_data_binding(text)
  279. 49 return quote(text) unless text.is_a?(String)
  280. 49 if text.match(/@\{([^}]+)\}/)
  281. 4 variable = $1
  282. 4 if variable.include?(' ?? ')
  283. 2 parts = variable.split(' ?? ')
  284. 2 var_name = parts[0].strip
  285. 2 "data.#{var_name}"
  286. else
  287. 2 "data.#{variable}"
  288. end
  289. else
  290. 45 quote(text)
  291. end
  292. end
  293. 1 def self.extract_variable_name(text)
  294. 5 if text && text.match(/@\{([^}]+)\}/)
  295. 3 $1.split('.').last
  296. else
  297. 2 'value'
  298. end
  299. end
  300. 1 def self.quote(text)
  301. # Escape special characters properly
  302. 52 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  303. .gsub('"', '\\"') # Escape quotes
  304. .gsub("\n", '\\n') # Escape newlines
  305. .gsub("\r", '\\r') # Escape carriage returns
  306. .gsub("\t", '\\t') # Escape tabs
  307. 52 "\"#{escaped}\""
  308. end
  309. 1 def self.indent(text, level)
  310. 493 return text if level == 0
  311. 402 spaces = ' ' * level
  312. 402 text.split("\n").map { |line|
  313. 405 line.empty? ? line : spaces + line
  314. }.join("\n")
  315. end
  316. end
  317. end
  318. end
  319. end

lib/compose/components/toggle_component.rb

96.23% lines covered

53 relevant lines. 51 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ToggleComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Toggle in iOS maps to Switch in Android
  10. 13 code = indent("Switch(", depth)
  11. # Checked state
  12. 13 checked_value = if json_data['data']
  13. 1 "@{#{json_data['data']}}"
  14. 12 elsif json_data['isOn']
  15. 1 json_data['isOn'].to_s
  16. else
  17. 11 'false'
  18. end
  19. # Process data binding
  20. 13 if checked_value.start_with?('@{')
  21. 1 variable = checked_value[2..-2]
  22. 1 code += "\n" + indent("checked = data.#{variable},", depth + 1)
  23. 1 code += "\n" + indent("onCheckedChange = { newValue -> data.#{variable} = newValue },", depth + 1)
  24. else
  25. 12 code += "\n" + indent("checked = #{checked_value},", depth + 1)
  26. # Handle onclick (lowercase) -> selector format only
  27. # onClick (camelCase) -> binding format only
  28. 12 if json_data['onclick']
  29. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
  30. 1 code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
  31. 11 elsif json_data['onClick']
  32. handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
  33. code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
  34. else
  35. 11 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  36. end
  37. end
  38. # Build modifiers
  39. 13 modifiers = []
  40. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. 13 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. 13 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  43. # Add weight modifier if in Row or Column
  44. 13 if parent_type == 'Row' || parent_type == 'Column'
  45. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  46. end
  47. 13 code += Helpers::ModifierBuilder.format(modifiers, depth)
  48. # Colors if specified
  49. 13 if json_data['tintColor'] || json_data['backgroundColor']
  50. 3 required_imports&.add(:switch_colors)
  51. 3 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  52. 3 if json_data['tintColor']
  53. 2 checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  54. 2 code += "\n" + indent("checkedThumbColor = #{checkedthumbcolor_resolved},", depth + 2)
  55. 2 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  56. 2 code += "\n" + indent("checkedTrackColor = #{checkedtrackcolor_resolved}.copy(alpha = 0.5f)", depth + 2)
  57. end
  58. 3 if json_data['backgroundColor']
  59. 2 code += ",\n" if json_data['tintColor']
  60. 2 uncheckedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  61. 2 code += "\n" + indent("uncheckedTrackColor = #{uncheckedtrackcolor_resolved}", depth + 2)
  62. end
  63. 3 code += "\n" + indent(")", depth + 1)
  64. end
  65. 13 code += "\n" + indent(")", depth)
  66. 13 code
  67. end
  68. 1 private
  69. 1 def self.indent(text, level)
  70. 67 return text if level == 0
  71. 40 spaces = ' ' * level
  72. 40 text.split("\n").map { |line|
  73. 42 line.empty? ? line : spaces + line
  74. }.join("\n")
  75. end
  76. end
  77. end
  78. end
  79. end

lib/compose/components/web_component.rb

100.0% lines covered

51 relevant lines. 51 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class WebComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 19 required_imports&.add(:webview)
  10. # Web uses 'url' for the web page URL
  11. 19 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  12. 2 variable = $1
  13. 2 "data.#{variable}"
  14. 17 elsif json_data['url']
  15. 2 "\"#{json_data['url']}\""
  16. else
  17. 15 '""'
  18. end
  19. # Generate WebView using AndroidView
  20. 19 code = indent("AndroidView(", depth)
  21. 19 code += "\n" + indent("factory = { context ->", depth + 1)
  22. 19 code += "\n" + indent("WebView(context).apply {", depth + 2)
  23. # WebView settings
  24. 19 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  25. 19 if json_data['userAgent']
  26. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  27. end
  28. 19 if json_data['allowZoom']
  29. 1 code += "\n" + indent("settings.builtInZoomControls = true", depth + 3)
  30. 1 code += "\n" + indent("settings.displayZoomControls = false", depth + 3)
  31. end
  32. # Load URL
  33. 19 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  34. # WebViewClient for handling navigation
  35. 19 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  36. # WebChromeClient for JavaScript alerts
  37. 19 if json_data['javaScriptEnabled'] != false
  38. 17 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  39. end
  40. 19 code += "\n" + indent("}", depth + 2)
  41. 19 code += "\n" + indent("},", depth + 1)
  42. # Update callback to handle URL changes
  43. 19 code += "\n" + indent("update = { webView ->", depth + 1)
  44. 19 if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  45. 2 code += "\n" + indent("webView.loadUrl(#{url})", depth + 2)
  46. end
  47. 19 code += "\n" + indent("},", depth + 1)
  48. # Build modifiers
  49. 19 modifiers = []
  50. # Default size for WebView
  51. 19 if !json_data['width'] && !json_data['height']
  52. 18 modifiers << ".fillMaxSize()"
  53. else
  54. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  55. end
  56. 19 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  57. 19 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  58. # Border for WebView
  59. 19 if json_data['borderWidth'] && json_data['borderColor']
  60. 1 required_imports&.add(:border)
  61. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports))"
  62. end
  63. 19 code += Helpers::ModifierBuilder.format(modifiers, depth)
  64. 19 code += "\n" + indent(")", depth)
  65. 19 code
  66. end
  67. 1 private
  68. 1 def self.indent(text, level)
  69. 236 return text if level == 0
  70. 197 spaces = ' ' * level
  71. 197 text.split("\n").map { |line|
  72. 200 line.empty? ? line : spaces + line
  73. }.join("\n")
  74. end
  75. end
  76. end
  77. end
  78. end

lib/compose/components/webview_component.rb

100.0% lines covered

41 relevant lines. 41 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class WebviewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 7 required_imports&.add(:webview)
  9. # WebView uses 'url' for the web page URL
  10. 7 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 6 elsif json_data['url']
  14. 1 "\"#{json_data['url']}\""
  15. else
  16. 5 '""'
  17. end
  18. # Generate WebView using AndroidView
  19. 7 code = indent("AndroidView(", depth)
  20. 7 code += "\n" + indent("factory = { context ->", depth + 1)
  21. 7 code += "\n" + indent("WebView(context).apply {", depth + 2)
  22. # WebView settings
  23. 7 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  24. 7 if json_data['userAgent']
  25. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  26. end
  27. 7 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  28. 7 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  29. # Load URL
  30. 7 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  31. 7 code += "\n" + indent("}", depth + 2)
  32. 7 code += "\n" + indent("},", depth + 1)
  33. # Build modifiers
  34. 7 modifiers = []
  35. 7 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  36. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  37. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  38. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  39. 7 if json_data['cornerRadius']
  40. 1 required_imports&.add(:shape)
  41. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  42. end
  43. 7 code += Helpers::ModifierBuilder.format(modifiers, depth)
  44. 7 code += "\n" + indent(")", depth)
  45. 7 code
  46. end
  47. 1 private
  48. 1 def self.indent(text, level)
  49. 71 return text if level == 0
  50. 57 spaces = ' ' * level
  51. 57 text.split("\n").map { |line|
  52. 57 line.empty? ? line : spaces + line
  53. }.join("\n")
  54. end
  55. end
  56. end
  57. end
  58. end

lib/compose/compose_builder.rb

56.24% lines covered

489 relevant lines. 275 lines covered and 214 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative '../core/logger'
  8. 1 require_relative 'style_loader'
  9. 1 require_relative 'data_model_updater'
  10. 1 require_relative 'helpers/import_manager'
  11. 1 require_relative 'helpers/modifier_builder'
  12. 1 require_relative 'components/text_component'
  13. 1 require_relative 'components/button_component'
  14. 1 require_relative 'components/textfield_component'
  15. 1 require_relative 'components/container_component'
  16. 1 require_relative 'components/image_component'
  17. 1 require_relative 'components/scrollview_component'
  18. 1 require_relative 'components/switch_component'
  19. 1 require_relative 'components/slider_component'
  20. 1 require_relative 'components/progress_component'
  21. 1 require_relative 'components/selectbox_component'
  22. 1 require_relative 'components/checkbox_component'
  23. 1 require_relative 'components/radio_component'
  24. 1 require_relative 'components/segment_component'
  25. 1 require_relative 'components/networkimage_component'
  26. 1 require_relative 'components/circleimage_component'
  27. 1 require_relative 'components/indicator_component'
  28. 1 require_relative 'components/textview_component'
  29. 1 require_relative 'components/collection_component'
  30. 1 require_relative 'components/table_component'
  31. 1 require_relative 'components/web_component'
  32. 1 require_relative 'components/gradientview_component'
  33. 1 require_relative 'components/blurview_component'
  34. 1 module KjuiTools
  35. 1 module Compose
  36. # Refactored ComposeBuilder - under 300 lines
  37. 1 class ComposeBuilder
  38. 1 def initialize
  39. 69 @config = Core::ConfigManager.load_config
  40. 69 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  41. 69 source_directory = @config['source_directory'] || 'src/main'
  42. 69 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  43. 69 @view_dir = File.join(@source_path, source_directory, @config['view_directory'] || 'kotlin/views')
  44. 69 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  45. 69 FileUtils.mkdir_p(@view_dir) unless File.exist?(@view_dir)
  46. end
  47. 1 def build(options = {})
  48. # Get all JSON files but exclude Resources folder
  49. 2 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  50. 1 file.include?('/Resources/')
  51. end
  52. 2 if json_files.empty?
  53. 2 Core::Logger.warn "No JSON files found in #{@layouts_dir}"
  54. 2 return
  55. end
  56. # Update data models first
  57. data_updater = DataModelUpdater.new
  58. data_updater.update_data_models
  59. # Build each JSON file
  60. json_files.each { |file| build_file(file) }
  61. end
  62. 1 def build_file(json_file)
  63. 5 base_name = File.basename(json_file, '.json')
  64. 5 snake_case_name = to_snake_case(base_name)
  65. 5 pascal_case_name = to_pascal_case(base_name)
  66. begin
  67. 5 json_content = File.read(json_file)
  68. 5 json_data = JSON.parse(json_content)
  69. 4 json_data = StyleLoader.load_and_merge(json_data)
  70. 4 @required_imports = Set.new
  71. 4 @included_views = Set.new
  72. 4 @cell_views = Set.new
  73. 4 @custom_components = Set.new
  74. # Find the GeneratedView file
  75. 4 generated_view_file = File.join(@view_dir, snake_case_name, "#{pascal_case_name}GeneratedView.kt")
  76. 4 if File.exist?(generated_view_file)
  77. update_generated_file(generated_view_file, json_data)
  78. else
  79. 4 Core::Logger.warn "GeneratedView file not found: #{generated_view_file}"
  80. end
  81. # Update ViewModel's updateData function
  82. 4 source_directory = @config['source_directory'] || 'src/main'
  83. 4 viewmodel_dir = File.join(@source_path, source_directory, @config['viewmodel_directory'] || 'kotlin/viewmodels')
  84. 4 viewmodel_file = File.join(viewmodel_dir, "#{pascal_case_name}ViewModel.kt")
  85. 4 if File.exist?(viewmodel_file)
  86. update_viewmodel_file(viewmodel_file, json_data, pascal_case_name)
  87. end
  88. 1 rescue JSON::ParserError => e
  89. 1 Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  90. rescue => e
  91. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  92. end
  93. end
  94. 1 private
  95. 1 def generate_component(json_data, depth = 0, parent_type = nil)
  96. 34 return "" unless json_data.is_a?(Hash)
  97. 32 component_type = json_data['type'] || 'View'
  98. # Handle includes
  99. 32 return generate_include(json_data, depth) if json_data['include']
  100. # Generate component based on type
  101. 32 case component_type
  102. when 'ScrollView', 'Scroll'
  103. 2 result = Components::ScrollViewComponent.generate(json_data, depth, @required_imports, parent_type)
  104. 2 handle_container_result(result, depth, parent_type)
  105. when 'SafeAreaView'
  106. generate_safe_area_view(json_data, depth)
  107. when 'View'
  108. 1 result = Components::ContainerComponent.generate(json_data, depth, @required_imports, parent_type)
  109. 1 handle_container_result(result, depth, parent_type)
  110. when 'Text', 'Label'
  111. 5 Components::TextComponent.generate(json_data, depth, @required_imports, parent_type)
  112. when 'Button'
  113. 1 Components::ButtonComponent.generate(json_data, depth, @required_imports, parent_type)
  114. when 'Image'
  115. 1 Components::ImageComponent.generate(json_data, depth, @required_imports, parent_type)
  116. when 'TextField'
  117. 1 Components::TextFieldComponent.generate(json_data, depth, @required_imports, parent_type)
  118. when 'Switch', 'Toggle'
  119. 2 Components::SwitchComponent.generate(json_data, depth, @required_imports, parent_type)
  120. when 'Slider'
  121. 1 Components::SliderComponent.generate(json_data, depth, @required_imports, parent_type)
  122. when 'Progress'
  123. 1 Components::ProgressComponent.generate(json_data, depth, @required_imports, parent_type)
  124. when 'SelectBox'
  125. 1 Components::SelectBoxComponent.generate(json_data, depth, @required_imports, parent_type)
  126. when 'Check', 'Checkbox', 'CheckBox'
  127. 2 Components::CheckboxComponent.generate(json_data, depth, @required_imports, parent_type)
  128. when 'Radio'
  129. 1 Components::RadioComponent.generate(json_data, depth, @required_imports, parent_type)
  130. when 'Segment'
  131. 1 Components::SegmentComponent.generate(json_data, depth, @required_imports, parent_type)
  132. when 'NetworkImage'
  133. 1 Components::NetworkImageComponent.generate(json_data, depth, @required_imports, parent_type)
  134. when 'CircleImage'
  135. 1 Components::CircleImageComponent.generate(json_data, depth, @required_imports, parent_type)
  136. when 'Indicator'
  137. 1 Components::IndicatorComponent.generate(json_data, depth, @required_imports, parent_type)
  138. when 'TextView'
  139. 1 Components::TextViewComponent.generate(json_data, depth, @required_imports, parent_type)
  140. when 'Collection'
  141. # Extract cell classes for imports
  142. 1 cell_classes = json_data['cellClasses'] || []
  143. 1 cell_classes.each do |cell_class|
  144. 1 @cell_views&.add(cell_class)
  145. end
  146. 1 Components::CollectionComponent.generate(json_data, depth, @required_imports, parent_type)
  147. when 'Table'
  148. 1 Components::TableComponent.generate(json_data, depth, @required_imports, parent_type)
  149. when 'Web'
  150. 1 Components::WebComponent.generate(json_data, depth, @required_imports, parent_type)
  151. when 'GradientView'
  152. 1 result = Components::GradientviewComponent.generate(json_data, depth, @required_imports, parent_type)
  153. 1 handle_container_result(result, depth, parent_type)
  154. when 'BlurView'
  155. 1 result = Components::BlurviewComponent.generate(json_data, depth, @required_imports, parent_type)
  156. 1 handle_container_result(result, depth, parent_type)
  157. when 'Spacer'
  158. 2 "Spacer(modifier = Modifier.height(#{json_data['height'] || 8}.dp))"
  159. else
  160. # Check for custom components
  161. 1 check_custom_component(component_type, json_data, depth, parent_type)
  162. end
  163. end
  164. 1 def check_custom_component(component_type, json_data, depth, parent_type)
  165. # Try to load custom component mappings if they exist
  166. 1 mappings_file = File.join(File.dirname(__FILE__), 'components', 'extensions', 'component_mappings.rb')
  167. 1 if File.exist?(mappings_file)
  168. require_relative 'components/extensions/component_mappings'
  169. if defined?(Components::Extensions::COMPONENT_MAPPINGS)
  170. component_class = Components::Extensions::COMPONENT_MAPPINGS[component_type]
  171. if component_class
  172. # Load the custom component file
  173. snake_case_name = component_type.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  174. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  175. .downcase
  176. component_file = File.join(File.dirname(__FILE__), 'components', 'extensions', "#{snake_case_name}_component.rb")
  177. if File.exist?(component_file)
  178. require_relative "components/extensions/#{snake_case_name}_component"
  179. # Add import for the custom component
  180. @custom_components&.add(component_type)
  181. result = component_class.generate(json_data, depth, @required_imports, parent_type)
  182. # Handle container components that return metadata
  183. if result.is_a?(Hash) && result[:children]
  184. return handle_container_result(result, depth, parent_type)
  185. else
  186. return result
  187. end
  188. end
  189. end
  190. end
  191. end
  192. 1 "// TODO: Implement component type: #{component_type}"
  193. end
  194. 1 def handle_container_result(result, depth, parent_type = nil)
  195. 7 if result.is_a?(Hash)
  196. 6 code = result[:code]
  197. 6 children = result[:children] || []
  198. 6 layout_type = result[:layout_type] || parent_type
  199. 6 json_data = result[:json_data]
  200. # Add lifecycle effects at the start of container content
  201. 6 if json_data && Helpers::ModifierBuilder.has_lifecycle_events?(json_data)
  202. lifecycle = Helpers::ModifierBuilder.build_lifecycle_effects(json_data, depth + 1, @required_imports)
  203. code += "\n" + lifecycle[:before] unless lifecycle[:before].empty?
  204. end
  205. 6 children.each do |child|
  206. 1 child_code = generate_component(child, depth + 1, layout_type)
  207. 1 code += "\n" + child_code unless child_code.empty?
  208. end
  209. 6 code += result[:closing] if result[:closing]
  210. 6 code
  211. else
  212. 1 result
  213. end
  214. end
  215. 1 def generate_safe_area_view(json_data, depth)
  216. # Parse edges - support both 'edges' and 'safeAreaInsetPositions' (alias)
  217. 3 edges_array = json_data['edges'] || json_data['safeAreaInsetPositions'] || ['all']
  218. 3 edges = edges_array.is_a?(Array) ? edges_array : [edges_array]
  219. # Parse orientation for child layout
  220. 3 orientation = json_data['orientation']
  221. # Determine container type based on orientation
  222. # No orientation = Box (like ZStack in SwiftUI)
  223. 3 container = case orientation
  224. when 'horizontal' then 'Row'
  225. when 'vertical' then 'Column'
  226. 3 else 'Box'
  227. end
  228. 3 code = indent("#{container}(", depth)
  229. # Build modifiers
  230. 3 modifiers = ["Modifier"]
  231. 3 modifiers << ".fillMaxWidth()"
  232. # Apply safe area padding based on edges
  233. 3 if edges.include?('all')
  234. 3 modifiers << ".systemBarsPadding()"
  235. else
  236. modifiers << ".statusBarsPadding()" if edges.include?('top')
  237. modifiers << ".navigationBarsPadding()" if edges.include?('bottom')
  238. # For start/end, use systemBarsPadding
  239. modifiers << ".systemBarsPadding()" if edges.include?('start') || edges.include?('end')
  240. end
  241. # Check if keyboard padding should be applied
  242. 3 ignore_keyboard = json_data['ignoreKeyboard'] == true
  243. 3 modifiers << ".imePadding()" unless ignore_keyboard
  244. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  245. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  246. 3 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, @required_imports))
  247. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  248. 3 code += "\n" + indent(") {", depth)
  249. # Get children - support both 'child' and 'children'
  250. 3 children = json_data['children'] || json_data['child'] || []
  251. 3 children = [children] unless children.is_a?(Array)
  252. 3 children.each do |child|
  253. 2 child_code = generate_component(child, depth + 1)
  254. 2 code += "\n" + child_code unless child_code.empty?
  255. end
  256. 3 code += "\n" + indent("}", depth)
  257. 3 code
  258. end
  259. 1 def generate_include(json_data, depth)
  260. 5 include_name = json_data['include']
  261. 5 pascal_name = to_pascal_case(include_name)
  262. 5 snake_name = to_snake_case(include_name)
  263. # Check if we should use DynamicView
  264. 5 use_dynamic = json_data['dynamic'] == true
  265. # Track this included view for imports
  266. 5 @included_views&.add(snake_name) unless use_dynamic
  267. # Track required imports for LaunchedEffect if we have data bindings
  268. 5 has_data_bindings = false
  269. # Check if there's data or shared_data to pass
  270. 5 include_data = json_data['data'] || {}
  271. 5 shared_data = json_data['shared_data'] || {}
  272. # Check for @{} bindings in data
  273. 5 include_data.each do |key, value|
  274. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  275. 1 has_data_bindings = true
  276. 1 unless use_dynamic
  277. 1 @required_imports.add(:LaunchedEffect)
  278. 1 @required_imports.add(:remember)
  279. end
  280. 1 break
  281. end
  282. end
  283. # If using dynamic view, generate DynamicView call
  284. 5 if use_dynamic
  285. 1 return generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  286. end
  287. # Generate unique instance ID for this include
  288. 4 instance_id = "#{to_camel_case(include_name)}Instance#{depth}"
  289. 4 code = ""
  290. # Create a remember block for the ViewModel instance
  291. 4 code += indent("val context = LocalContext.current", depth)
  292. 4 code += "\n"
  293. 4 code += indent("val #{instance_id} = remember { #{pascal_name}ViewModel(context.applicationContext as Application) }", depth)
  294. 4 code += "\n"
  295. # If we have data bindings, add LaunchedEffect to update on parent data changes
  296. 4 if has_data_bindings || shared_data.any?
  297. 2 code += "\n" + indent("// Update included view when parent data changes", depth)
  298. 2 code += "\n" + indent("LaunchedEffect(", depth)
  299. # Add keys for all bound variables
  300. 2 keys = []
  301. 2 include_data.each do |key, value|
  302. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  303. 1 variable = $1
  304. 1 keys << "data.#{variable}"
  305. end
  306. end
  307. 2 shared_data.each do |key, value|
  308. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  309. 1 variable = $1
  310. 1 keys << "data.#{variable}"
  311. end
  312. end
  313. 2 if keys.any?
  314. 2 code += keys.join(", ")
  315. else
  316. code += "Unit"
  317. end
  318. 2 code += ") {"
  319. 2 code += "\n" + indent("val updates = mutableMapOf<String, Any>()", depth + 1)
  320. # Process data (one-way binding from parent to child)
  321. 2 include_data.each do |key, value|
  322. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  323. # This is a data binding reference to parent data
  324. 1 variable = $1
  325. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  326. else
  327. # This is a static value
  328. formatted_value = format_value_for_kotlin(value)
  329. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  330. end
  331. end
  332. # Process shared_data (two-way binding)
  333. 2 if shared_data.any?
  334. 1 code += "\n" + indent("// Shared data for two-way binding", depth + 1)
  335. 1 shared_data.each do |key, value|
  336. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  337. # This creates a two-way binding
  338. 1 variable = $1
  339. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  340. # TODO: Also need to update parent when child changes
  341. else
  342. # Static value for shared_data
  343. formatted_value = format_value_for_kotlin(value)
  344. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  345. end
  346. end
  347. end
  348. 2 code += "\n" + indent("#{instance_id}.updateData(updates)", depth + 1)
  349. 2 code += "\n" + indent("}", depth)
  350. end
  351. # Generate the included view call
  352. 4 code += "\n" + indent("#{pascal_name}View(", depth)
  353. 4 code += "\n" + indent("viewModel = #{instance_id}", depth + 1)
  354. 4 code += "\n" + indent(")", depth)
  355. 4 code
  356. end
  357. 1 def generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  358. 1 include_name = json_data['include']
  359. # Add required imports for SafeDynamicView
  360. 1 @required_imports.add(:safe_dynamic_view)
  361. 1 code = ""
  362. # Build data map from bindings and current data
  363. 1 code += indent("// Build data map with bindings", depth)
  364. 1 code += "\n" + indent("val dynamicData = mutableMapOf<String, Any>()", depth)
  365. # Add all current data values
  366. 1 code += "\n" + indent("// Add current data values", depth)
  367. 1 code += "\n" + indent("data.forEach { (key, value) ->", depth)
  368. 1 code += "\n" + indent("dynamicData[key] = value", depth + 1)
  369. 1 code += "\n" + indent("}", depth)
  370. # Process include_data bindings
  371. 1 if include_data.any?
  372. code += "\n" + indent("// Process include data bindings", depth)
  373. include_data.each do |key, value|
  374. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  375. # This is a data binding reference to parent data
  376. variable = $1
  377. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  378. else
  379. # This is a static value
  380. formatted_value = format_value_for_kotlin(value)
  381. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  382. end
  383. end
  384. end
  385. # Process shared_data bindings
  386. 1 if shared_data.any?
  387. code += "\n" + indent("// Process shared data bindings", depth)
  388. shared_data.each do |key, value|
  389. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  390. # This creates a two-way binding
  391. variable = $1
  392. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  393. else
  394. # Static value for shared_data
  395. formatted_value = format_value_for_kotlin(value)
  396. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  397. end
  398. end
  399. end
  400. # Add all viewModel methods as functions to the data map
  401. 1 code += "\n" + indent("// Add viewModel methods as event handlers", depth)
  402. 1 code += "\n" + indent("// Note: Add specific method references as needed", depth)
  403. 1 code += "\n" + indent("// Example: dynamicData[\"onButtonClick\"] = { viewModel.onButtonClick() }", depth)
  404. # Call SafeDynamicView
  405. 1 code += "\n" + indent("// Render dynamic view", depth)
  406. 1 code += "\n" + indent("SafeDynamicView(", depth)
  407. 1 code += "\n" + indent("layoutName = \"#{include_name}\",", depth + 1)
  408. 1 code += "\n" + indent("data = dynamicData", depth + 1)
  409. 1 code += "\n" + indent(")", depth)
  410. 1 code
  411. end
  412. 1 def format_value_for_kotlin(value)
  413. 7 case value
  414. when String
  415. 1 "\"#{value.gsub('"', '\\"')}\""
  416. when Integer
  417. 1 value.to_s
  418. when Float
  419. 1 "#{value}f"
  420. when TrueClass, FalseClass
  421. 2 value.to_s
  422. when nil
  423. 1 "null"
  424. else
  425. 1 "\"#{value}\""
  426. end
  427. end
  428. 1 def update_generated_file(file_path, json_data)
  429. existing_content = File.read(file_path)
  430. if existing_content.include?('// >>> GENERATED_CODE_START') &&
  431. existing_content.include?('// >>> GENERATED_CODE_END')
  432. # Extract the layout name from file path
  433. layout_name = File.basename(File.dirname(file_path))
  434. view_name = to_pascal_case(layout_name)
  435. # Generate both static and dynamic versions
  436. static_content = generate_component(json_data, 1)
  437. dynamic_content = generate_dynamic_view_content(layout_name, json_data, 1)
  438. # Create content that switches based on DynamicModeManager
  439. composable_content = generate_mode_aware_content(layout_name, static_content, dynamic_content, 1)
  440. updated_content = existing_content.gsub(
  441. /\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
  442. "// >>> GENERATED_CODE_START\n#{composable_content} // >>> GENERATED_CODE_END"
  443. )
  444. # Update function signature to include viewModel parameter
  445. updated_content = updated_content.gsub(
  446. /fun #{view_name}GeneratedView\(\s*\n\s*data: #{view_name}Data\s*\n\s*\)/m,
  447. "fun #{view_name}GeneratedView(\n data: #{view_name}Data,\n viewModel: #{view_name}ViewModel\n)"
  448. )
  449. # Add ViewModel import if not present
  450. viewmodel_import = "import #{@package_name}.viewmodels.#{view_name}ViewModel"
  451. unless updated_content.include?(viewmodel_import)
  452. # Add after Data import
  453. data_import = "import #{@package_name}.data.#{view_name}Data"
  454. updated_content = updated_content.gsub(data_import, "#{data_import}\n#{viewmodel_import}")
  455. end
  456. updated_content = update_imports(updated_content)
  457. File.write(file_path, updated_content)
  458. Core::Logger.success "Updated: #{file_path}"
  459. else
  460. Core::Logger.warn "Generated code markers not found in #{file_path}"
  461. end
  462. end
  463. 1 def update_viewmodel_file(file_path, json_data, view_name)
  464. existing_content = File.read(file_path)
  465. # Check if the file has generated code markers
  466. unless existing_content.include?('// >>> GENERATED_CODE_START') &&
  467. existing_content.include?('// >>> GENERATED_CODE_END')
  468. return # Skip files without markers
  469. end
  470. # Extract data properties from JSON
  471. data_properties = extract_data_properties(json_data)
  472. # Generate the updateData function content
  473. update_data_content = generate_update_data_function(data_properties, view_name)
  474. # Replace the generated section
  475. updated_content = existing_content.gsub(
  476. /\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
  477. "// >>> GENERATED_CODE_START\n#{update_data_content} // >>> GENERATED_CODE_END"
  478. )
  479. # Add kotlinx.coroutines.flow.update import if not present
  480. update_import = "import kotlinx.coroutines.flow.update"
  481. unless updated_content.include?(update_import)
  482. # Add after asStateFlow import
  483. as_state_flow_import = "import kotlinx.coroutines.flow.asStateFlow"
  484. if updated_content.include?(as_state_flow_import)
  485. updated_content = updated_content.gsub(as_state_flow_import, "#{as_state_flow_import}\n#{update_import}")
  486. end
  487. end
  488. File.write(file_path, updated_content)
  489. Core::Logger.success "Updated ViewModel: #{file_path}"
  490. end
  491. 1 def extract_data_properties(json_data, properties = [])
  492. if json_data.is_a?(Hash)
  493. # Stop if this is an include - includes have their own data models
  494. return properties if json_data['include']
  495. # Check for data section at any level, but only process the first one found
  496. if json_data['data'] && properties.empty?
  497. if json_data['data'].is_a?(Array)
  498. json_data['data'].each do |data_item|
  499. if data_item.is_a?(Hash) && data_item['name']
  500. unless properties.any? { |p| p['name'] == data_item['name'] }
  501. properties << data_item
  502. end
  503. end
  504. end
  505. end
  506. end
  507. # If we haven't found data yet, continue searching in children
  508. if properties.empty? && json_data['child']
  509. if json_data['child'].is_a?(Array)
  510. json_data['child'].each do |child|
  511. extract_data_properties(child, properties)
  512. break unless properties.empty?
  513. end
  514. else
  515. extract_data_properties(json_data['child'], properties)
  516. end
  517. end
  518. elsif json_data.is_a?(Array)
  519. json_data.each do |item|
  520. extract_data_properties(item, properties)
  521. break unless properties.empty?
  522. end
  523. end
  524. properties
  525. end
  526. 1 def generate_update_data_function(data_properties, view_name)
  527. code = " // Auto-generated updateData function - updated by 'kjui build'\n"
  528. code += " fun updateData(updates: Map<String, Any>) {\n"
  529. code += " _data.update { current ->\n"
  530. code += " var updated = current\n"
  531. code += " updates.forEach { (key, value) ->\n"
  532. code += " updated = when (key) {\n"
  533. if data_properties.empty?
  534. code += " else -> updated\n"
  535. else
  536. data_properties.each do |prop|
  537. name = prop['name']
  538. class_type = prop['class'] || 'String'
  539. kotlin_cast = get_kotlin_cast(class_type, name)
  540. code += " \"#{name}\" -> updated.copy(#{name} = #{kotlin_cast})\n"
  541. end
  542. code += " else -> updated\n"
  543. end
  544. code += " }\n"
  545. code += " }\n"
  546. code += " updated\n"
  547. code += " }\n"
  548. code += " }\n"
  549. code
  550. end
  551. 1 def get_kotlin_cast(class_type, name)
  552. case class_type
  553. when 'String'
  554. "value as? String ?: updated.#{name}"
  555. when 'Int'
  556. "(value as? Number)?.toInt() ?: updated.#{name}"
  557. when 'Double'
  558. "(value as? Number)?.toDouble() ?: updated.#{name}"
  559. when 'Float', 'CGFloat'
  560. "(value as? Number)?.toFloat() ?: updated.#{name}"
  561. when 'Bool', 'Boolean'
  562. "value as? Boolean ?: updated.#{name}"
  563. else
  564. "value as? #{class_type} ?: updated.#{name}"
  565. end
  566. end
  567. 1 def generate_mode_aware_content(layout_name, static_content, dynamic_content, depth)
  568. indent_str = " " * depth
  569. code = ""
  570. code += "#{indent_str}// Check if Dynamic Mode is active\n"
  571. code += "#{indent_str}if (DynamicModeManager.isActive()) {\n"
  572. code += "#{indent_str} // Dynamic Mode - use SafeDynamicView for real-time updates\n"
  573. code += dynamic_content
  574. code += "#{indent_str}} else {\n"
  575. code += "#{indent_str} // Static Mode - use generated code\n"
  576. code += " #{static_content}"
  577. code += "#{indent_str}}\n"
  578. # Add required imports for DynamicModeManager
  579. @required_imports.add(:dynamic_mode_manager)
  580. # SafeDynamicView import is already added in generate_dynamic_view
  581. code
  582. end
  583. 1 def generate_dynamic_view_content(layout_name, json_data, depth)
  584. indent_str = " " * depth
  585. code = ""
  586. code += "#{indent_str} SafeDynamicView(\n"
  587. code += "#{indent_str} layoutName = \"#{layout_name}\",\n"
  588. code += "#{indent_str} data = data.toMap(),\n"
  589. code += "#{indent_str} fallback = {\n"
  590. code += "#{indent_str} // Show error or loading state when dynamic view is not available\n"
  591. code += "#{indent_str} Box(\n"
  592. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  593. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  594. code += "#{indent_str} ) {\n"
  595. code += "#{indent_str} Text(\n"
  596. code += "#{indent_str} text = \"Dynamic view not available\",\n"
  597. code += "#{indent_str} color = Color.Gray\n"
  598. code += "#{indent_str} )\n"
  599. code += "#{indent_str} }\n"
  600. code += "#{indent_str} },\n"
  601. code += "#{indent_str} onError = { error ->\n"
  602. code += "#{indent_str} // Log error or show error UI\n"
  603. code += "#{indent_str} android.util.Log.e(\"DynamicView\", \"Error loading #{layout_name}: \\$error\")\n"
  604. code += "#{indent_str} },\n"
  605. code += "#{indent_str} onLoading = {\n"
  606. code += "#{indent_str} // Show loading indicator\n"
  607. code += "#{indent_str} Box(\n"
  608. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  609. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  610. code += "#{indent_str} ) {\n"
  611. code += "#{indent_str} CircularProgressIndicator()\n"
  612. code += "#{indent_str} }\n"
  613. code += "#{indent_str} }\n"
  614. code += "#{indent_str} ) { jsonContent ->\n"
  615. code += "#{indent_str} // Parse and render the dynamic JSON content\n"
  616. code += "#{indent_str} // This will be handled by the DynamicView implementation\n"
  617. code += "#{indent_str} }\n"
  618. # Add required imports
  619. @required_imports.add(:safe_dynamic_view)
  620. @required_imports.add(:circular_progress_indicator)
  621. @required_imports.add(:box)
  622. code
  623. end
  624. 1 def update_imports(content)
  625. 1 imports_map = Helpers::ImportManager.get_imports_map(@package_name)
  626. 1 imports_to_add = []
  627. 1 @required_imports.each do |import_type|
  628. 1 import_lines = imports_map[import_type]
  629. 1 if import_lines
  630. if import_lines.is_a?(Array)
  631. imports_to_add.concat(import_lines)
  632. else
  633. imports_to_add << import_lines
  634. end
  635. end
  636. end
  637. # Add imports for included views
  638. 1 if @included_views && @included_views.any?
  639. # Add necessary imports for creating ViewModels
  640. imports_to_add << "import android.app.Application" unless imports_to_add.include?("import android.app.Application")
  641. imports_to_add << "import androidx.compose.ui.platform.LocalContext" unless imports_to_add.include?("import androidx.compose.ui.platform.LocalContext")
  642. @included_views.each do |view_name|
  643. pascal_name = to_pascal_case(view_name)
  644. view_import = "import #{@package_name}.views.#{view_name}.#{pascal_name}View"
  645. data_import = "import #{@package_name}.data.#{pascal_name}Data"
  646. viewmodel_import = "import #{@package_name}.viewmodels.#{pascal_name}ViewModel"
  647. imports_to_add << view_import unless imports_to_add.include?(view_import)
  648. imports_to_add << data_import unless imports_to_add.include?(data_import)
  649. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  650. end
  651. end
  652. # Add imports for custom components
  653. 1 if @custom_components && @custom_components.any?
  654. @custom_components.each do |component_name|
  655. component_import = "import #{@package_name}.extensions.#{component_name}"
  656. imports_to_add << component_import unless imports_to_add.include?(component_import)
  657. end
  658. end
  659. # Add imports for cell views (used in Collection components)
  660. 1 if @cell_views && @cell_views.any?
  661. # Add necessary imports for creating ViewModels in collections
  662. imports_to_add << "import androidx.lifecycle.viewmodel.compose.viewModel" unless imports_to_add.include?("import androidx.lifecycle.viewmodel.compose.viewModel")
  663. # First, remove any old/incorrect cell view imports
  664. lines = content.split("\n")
  665. @cell_views.each do |cell_class|
  666. snake_name = to_snake_case(cell_class)
  667. # Remove any existing imports with incorrect capitalization
  668. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.views\.#{Regexp.escape(snake_name)}\.\w+View$/) }
  669. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.data\.\w+Data$/) && line.downcase.include?(cell_class.downcase) }
  670. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.viewmodels\.\w+ViewModel$/) && line.downcase.include?(cell_class.downcase) }
  671. end
  672. content = lines.join("\n")
  673. @cell_views.each do |cell_class|
  674. # Cell class names are already in PascalCase (e.g., "ProductCell")
  675. # Convert to snake_case for folder path
  676. snake_name = to_snake_case(cell_class)
  677. # Keep the original cell class name for the class itself
  678. # Add imports for the cell view and data
  679. view_import = "import #{@package_name}.views.#{snake_name}.#{cell_class}View"
  680. data_import = "import #{@package_name}.data.#{cell_class}Data"
  681. viewmodel_import = "import #{@package_name}.viewmodels.#{cell_class}ViewModel"
  682. imports_to_add << view_import unless imports_to_add.include?(view_import)
  683. imports_to_add << data_import unless imports_to_add.include?(data_import)
  684. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  685. end
  686. end
  687. 1 if imports_to_add.any?
  688. lines = content.split("\n")
  689. package_index = lines.find_index { |line| line.start_with?("package ") }
  690. if package_index
  691. last_import_index = lines.each_with_index.select { |line, i|
  692. i > package_index && line.start_with?("import ")
  693. }.map(&:last).max || package_index
  694. imports_to_add.each do |import|
  695. unless lines.any? { |line| line == import }
  696. lines.insert(last_import_index + 1, import)
  697. last_import_index += 1
  698. end
  699. end
  700. content = lines.join("\n")
  701. end
  702. end
  703. 1 content
  704. end
  705. 1 def process_data_binding(text)
  706. 3 return quote(text) unless text.is_a?(String)
  707. 3 if text.match(/@\{([^}]+)\}/)
  708. 2 variable = $1
  709. 2 if variable.include?(' ?? ')
  710. 1 var_name = variable.split(' ?? ')[0].strip
  711. 1 "\"\${data.#{var_name}}\""
  712. else
  713. 1 "\"\${data.#{variable}}\""
  714. end
  715. else
  716. 1 quote(text)
  717. end
  718. end
  719. 1 def quote(text)
  720. # Escape special characters properly
  721. 4 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  722. .gsub('"', '\\"') # Escape quotes
  723. .gsub("\n", '\\n') # Escape newlines
  724. .gsub("\r", '\\r') # Escape carriage returns
  725. .gsub("\t", '\\t') # Escape tabs
  726. 4 "\"#{escaped}\""
  727. end
  728. 1 def indent(text, level)
  729. 58 return text if level == 0
  730. 15 spaces = ' ' * level
  731. 15 text.split("\n").map { |line|
  732. 15 line.empty? ? line : spaces + line
  733. }.join("\n")
  734. end
  735. 1 def to_pascal_case(str)
  736. 17 str.split(/[_\-]/).map(&:capitalize).join
  737. end
  738. 1 def to_camel_case(str)
  739. 5 pascal = to_pascal_case(str)
  740. 5 pascal[0].downcase + pascal[1..-1]
  741. end
  742. 1 def to_snake_case(str)
  743. 11 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  744. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  745. .downcase
  746. end
  747. end
  748. end
  749. end

lib/compose/data_model_updater.rb

86.38% lines covered

235 relevant lines. 203 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative '../core/type_converter'
  8. 1 require_relative 'style_loader'
  9. 1 module KjuiTools
  10. 1 module Compose
  11. 1 class DataModelUpdater
  12. 1 def initialize
  13. 52 @config = Core::ConfigManager.load_config
  14. 52 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  15. 52 source_directory = @config['source_directory'] || 'src/main'
  16. 52 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  17. 52 @data_dir = File.join(@source_path, source_directory, @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data')
  18. 52 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  19. 52 @mode = @config['mode'] || 'compose'
  20. end
  21. 1 def update_data_models(files_to_update = nil)
  22. # If specific files provided, only update those
  23. 11 if files_to_update && !files_to_update.empty?
  24. 3 puts " Updating data models for #{files_to_update.length} modified files..."
  25. 3 files_to_update.each do |json_file|
  26. 3 process_json_file(json_file)
  27. end
  28. else
  29. # Process all JSON files in Layouts directory but exclude Resources and Styles folders
  30. 8 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  31. # Skip Resources and Styles folders (styles don't need data models)
  32. 9 file.include?('/Resources/') || file.include?('/Styles/')
  33. end
  34. 8 puts " Updating data models for #{json_files.length} files..."
  35. 8 json_files.each do |json_file|
  36. 8 process_json_file(json_file)
  37. end
  38. end
  39. end
  40. 1 private
  41. 1 def process_json_file(json_file)
  42. 11 json_content = File.read(json_file)
  43. 11 json_data = JSON.parse(json_content)
  44. # Load and merge styles into the JSON data
  45. 11 json_data = StyleLoader.load_and_merge(json_data)
  46. # Extract data properties from JSON
  47. 11 data_properties = extract_data_properties(json_data)
  48. # Extract onclick actions from JSON (now includes actions from styles)
  49. 11 onclick_actions = extract_onclick_actions(json_data)
  50. # Always create/update data file, even if no properties
  51. # Get the view name from file path
  52. 11 base_name = File.basename(json_file, '.json')
  53. # Update the Data model file
  54. 11 update_data_file(base_name, data_properties, onclick_actions)
  55. end
  56. 1 def extract_onclick_actions(json_data, actions = Set.new)
  57. 29 if json_data.is_a?(Hash)
  58. # Check for onclick attribute
  59. 28 if json_data['onclick'] && json_data['onclick'].is_a?(String)
  60. 8 actions.add(json_data['onclick'])
  61. end
  62. # Process children
  63. 28 if json_data['child']
  64. 10 if json_data['child'].is_a?(Array)
  65. 8 json_data['child'].each do |child|
  66. 10 extract_onclick_actions(child, actions)
  67. end
  68. else
  69. 2 extract_onclick_actions(json_data['child'], actions)
  70. end
  71. end
  72. 1 elsif json_data.is_a?(Array)
  73. 1 json_data.each do |item|
  74. 2 extract_onclick_actions(item, actions)
  75. end
  76. end
  77. 29 actions.to_a
  78. end
  79. 1 def extract_data_properties(json_data, properties = [], depth = 0)
  80. 19 if json_data.is_a?(Hash)
  81. # Stop if this is an include - includes have their own data models
  82. 19 return properties if json_data['include']
  83. # Check for data section at any level, but only process the first one found
  84. 18 if json_data['data'] && properties.empty?
  85. 5 if json_data['data'].is_a?(Array)
  86. 4 json_data['data'].each do |data_item|
  87. 5 if data_item.is_a?(Hash) && data_item['name']
  88. # Check if property already exists (by name) to avoid duplicates
  89. 6 unless properties.any? { |p| p['name'] == data_item['name'] }
  90. # Normalize type using TypeConverter with mode
  91. 5 normalized = Core::TypeConverter.normalize_data_property(data_item, @mode)
  92. 5 properties << normalized
  93. end
  94. end
  95. end
  96. 1 elsif json_data['data'].is_a?(Hash)
  97. # Handle simple data object format from styles
  98. 1 json_data['data'].each do |name, value|
  99. 10 unless properties.any? { |p| p['name'] == name }
  100. # Infer type from value
  101. 4 class_type = if value.is_a?(Integer)
  102. 1 'Int'
  103. 3 elsif value.is_a?(Float)
  104. 1 'Float'
  105. 2 elsif value.is_a?(TrueClass) || value.is_a?(FalseClass)
  106. 1 'Boolean'
  107. else
  108. 1 'String'
  109. end
  110. 4 properties << {
  111. 'name' => name,
  112. 'class' => class_type,
  113. 'defaultValue' => value
  114. }
  115. end
  116. end
  117. end
  118. end
  119. # If we haven't found data yet, continue searching in children
  120. 18 if properties.empty? && json_data['child']
  121. 7 if json_data['child'].is_a?(Array)
  122. 6 json_data['child'].each do |child|
  123. 7 extract_data_properties(child, properties, depth + 1)
  124. # Stop after finding the first data section
  125. 7 break unless properties.empty?
  126. end
  127. else
  128. 1 extract_data_properties(json_data['child'], properties, depth + 1)
  129. end
  130. end
  131. elsif json_data.is_a?(Array)
  132. json_data.each do |item|
  133. extract_data_properties(item, properties, depth)
  134. # Stop after finding the first data section
  135. break unless properties.empty?
  136. end
  137. end
  138. 18 properties
  139. end
  140. 1 def update_data_file(base_name, data_properties, onclick_actions = [])
  141. # Convert base_name to PascalCase for searching
  142. 11 pascal_view_name = to_pascal_case(base_name)
  143. # Check for existing file with different casing
  144. 11 existing_file = find_existing_data_file(pascal_view_name)
  145. 11 if existing_file
  146. # Extract the actual data class name from the existing file
  147. existing_class_name = extract_class_name(existing_file)
  148. if existing_class_name
  149. # Use the exact class name from the existing file
  150. view_name = existing_class_name.sub(/Data$/, '')
  151. else
  152. # Fallback to pascal case if we can't extract the name
  153. view_name = pascal_view_name
  154. end
  155. data_file_path = existing_file
  156. else
  157. # For new files, use pascal case
  158. 11 view_name = pascal_view_name
  159. 11 data_file_path = File.join(@data_dir, "#{view_name}Data.kt")
  160. # If file doesn't exist, create it with empty data structure
  161. 11 unless File.exist?(data_file_path)
  162. # Create directory if needed
  163. 11 FileUtils.mkdir_p(@data_dir)
  164. end
  165. end
  166. # Generate new content
  167. 11 content = generate_data_content(view_name, data_properties, onclick_actions)
  168. # Write the updated content
  169. 11 File.write(data_file_path, content)
  170. 11 puts " Updated Data model: #{data_file_path}"
  171. end
  172. 1 def find_existing_data_file(view_name)
  173. # Try exact match first
  174. 13 exact_path = File.join(@data_dir, "#{view_name}Data.kt")
  175. 13 return exact_path if File.exist?(exact_path)
  176. # Try case-insensitive search
  177. 12 Dir.glob(File.join(@data_dir, '*Data.kt')).find do |file|
  178. File.basename(file, '.kt').downcase == "#{view_name}Data".downcase
  179. end
  180. end
  181. 1 def extract_class_name(file_path)
  182. 2 content = File.read(file_path)
  183. 2 if match = content.match(/data\s+class\s+(\w+Data)\s*\(/)
  184. 1 match[1]
  185. else
  186. nil
  187. end
  188. end
  189. 1 def generate_data_content(view_name, data_properties, onclick_actions = [])
  190. 15 content = <<~KOTLIN
  191. // Generated by kjui_tools - DO NOT EDIT
  192. package #{@package_name}.data
  193. KOTLIN
  194. # Add Color import if any property uses Color type
  195. 27 if data_properties.any? { |prop| prop['class'] == 'Color' }
  196. 1 content += "import androidx.compose.ui.graphics.Color\n"
  197. end
  198. 15 content += "\ndata class #{view_name}Data(\n"
  199. 15 if data_properties.empty?
  200. 7 content += " // No data properties defined in JSON\n"
  201. 7 content += " val placeholder: String = \"placeholder\"\n"
  202. else
  203. # Add each property with correct type and default value
  204. 8 data_properties.each_with_index do |prop, index|
  205. 12 name = prop['name']
  206. 12 class_type = map_to_kotlin_type(prop['class'])
  207. 12 default_value = prop['defaultValue']
  208. # If no default value or nil, make it nullable
  209. 12 if default_value.nil? || default_value == 'nil'
  210. # Don't add ? if type already ends with ? (already nullable)
  211. 1 if class_type.end_with?('?')
  212. content += " var #{name}: #{class_type} = null"
  213. else
  214. 1 content += " var #{name}: #{class_type}? = null"
  215. end
  216. else
  217. 11 formatted_value = format_default_value(default_value, prop['class'])
  218. 11 content += " var #{name}: #{class_type} = #{formatted_value}"
  219. end
  220. # Add comma if not last property
  221. 12 content += "," if index < data_properties.length - 1
  222. 12 content += "\n"
  223. end
  224. end
  225. 15 content += ") {\n"
  226. # Add companion object with update function
  227. 15 content += " companion object {\n"
  228. 15 content += " // Update properties from map\n"
  229. 15 content += " fun fromMap(map: Map<String, Any>): #{view_name}Data {\n"
  230. 15 content += " return #{view_name}Data(\n"
  231. 15 if !data_properties.empty?
  232. 8 data_properties.each_with_index do |prop, index|
  233. 12 name = prop['name']
  234. 12 class_type = prop['class']
  235. 12 kotlin_type = map_to_kotlin_type(class_type)
  236. # Generate conversion code based on type
  237. 12 content += " #{name} = "
  238. 12 case class_type
  239. when 'String'
  240. 8 content += "map[\"#{name}\"] as? String ?: \"\""
  241. when 'Int'
  242. 1 content += "(map[\"#{name}\"] as? Number)?.toInt() ?: 0"
  243. when 'Double'
  244. content += "(map[\"#{name}\"] as? Number)?.toDouble() ?: 0.0"
  245. when 'Float'
  246. 1 content += "(map[\"#{name}\"] as? Number)?.toFloat() ?: 0f"
  247. when 'Bool', 'Boolean'
  248. 1 content += "map[\"#{name}\"] as? Boolean ?: false"
  249. when 'Color'
  250. 1 content += "map[\"#{name}\"] as? Color ?: Color.Unspecified"
  251. when 'CollectionDataSource'
  252. content += "com.kotlinjsonui.data.CollectionDataSource()"
  253. when /^List<.*>$/
  254. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyList()"
  255. when /^Map<.*>$/
  256. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyMap()"
  257. else
  258. # For custom types, try to cast directly
  259. content += "map[\"#{name}\"] as? #{kotlin_type}"
  260. end
  261. 12 content += "," if index < data_properties.length - 1
  262. 12 content += "\n"
  263. end
  264. else
  265. 7 content += " placeholder = \"placeholder\"\n"
  266. end
  267. 15 content += " )\n"
  268. 15 content += " }\n"
  269. 15 content += " }\n"
  270. # Add toMap function
  271. 15 content += "\n"
  272. 15 content += " // Convert properties to map for runtime use\n"
  273. 15 content += " fun toMap(): MutableMap<String, Any> {\n"
  274. 15 content += " val map = mutableMapOf<String, Any>()\n"
  275. # Add data properties
  276. 15 if !data_properties.empty?
  277. 8 content += " \n"
  278. 8 content += " // Data properties\n"
  279. 8 data_properties.each do |prop|
  280. 12 name = prop['name']
  281. 12 default_value = prop['defaultValue']
  282. # If it's nullable, check for null
  283. 12 if default_value.nil? || default_value == 'nil'
  284. 1 content += " #{name}?.let { map[\"#{name}\"] = it }\n"
  285. else
  286. 11 content += " map[\"#{name}\"] = #{name}\n"
  287. end
  288. end
  289. end
  290. 15 if data_properties.empty?
  291. 7 content += " // No properties to add\n"
  292. end
  293. 15 content += " \n"
  294. 15 content += " return map\n"
  295. 15 content += " }\n"
  296. 15 content += "}\n"
  297. 15 content
  298. end
  299. 1 def map_to_kotlin_type(json_class)
  300. 38 case json_class
  301. when 'String'
  302. 17 'String'
  303. when 'Int'
  304. 3 'Int'
  305. when 'Double'
  306. 1 'Double'
  307. when 'Float'
  308. 3 'Float'
  309. when 'Bool', 'Boolean'
  310. 4 'Boolean'
  311. when 'CGFloat'
  312. 1 'Float'
  313. when 'Color'
  314. 3 'Color'
  315. when 'CollectionDataSource'
  316. # Use the actual CollectionDataSource type
  317. 1 'com.kotlinjsonui.data.CollectionDataSource'
  318. when /^\(\) -> Unit$/
  319. # Non-optional callback becomes optional in data class
  320. 1 '(() -> Unit)?'
  321. when /^\((.+)\) -> Unit$/
  322. # Callback with parameters becomes optional
  323. 1 "((#{$1}) -> Unit)?"
  324. when /^\(\(\) -> Unit\)\?$/
  325. # Already optional, keep as is
  326. 1 '(() -> Unit)?'
  327. when /^\(\((.+)\) -> Unit\)\?$/
  328. # Already optional with params, keep as is
  329. 1 "((#{$1}) -> Unit)?"
  330. else
  331. # Return as-is for custom types
  332. 1 json_class
  333. end
  334. end
  335. 1 def format_default_value(value, json_class)
  336. 29 case json_class
  337. when 'String'
  338. # Handle '' as empty string (common shorthand)
  339. 8 if value == "''"
  340. '""'
  341. else
  342. # For String class, add quotes
  343. 8 "\"#{value}\""
  344. end
  345. when 'Bool', 'Boolean'
  346. # Convert to boolean
  347. 4 if value.is_a?(TrueClass) || value.is_a?(FalseClass)
  348. 3 value.to_s
  349. else
  350. 1 value.to_s.downcase == 'true' ? 'true' : 'false'
  351. end
  352. when 'Int'
  353. # Ensure it's an integer
  354. 3 value.to_i.to_s
  355. when 'Double'
  356. # Ensure it's a double
  357. 1 "#{value.to_f}"
  358. when 'Float', 'CGFloat'
  359. # Ensure it's a float with f suffix
  360. 3 "#{value.to_f}f"
  361. when 'Color'
  362. # Handle color values
  363. 5 if value.is_a?(String) && value.start_with?('#')
  364. 2 "Color(android.graphics.Color.parseColor(\"#{value}\"))"
  365. 3 elsif value.is_a?(String) && value.start_with?('Color.')
  366. 1 value # Direct Color reference like Color.Red
  367. 2 elsif value.is_a?(String) && value.downcase.include?('color')
  368. # Map common color names
  369. case value.downcase
  370. when 'red' then 'Color.Red'
  371. when 'green' then 'Color.Green'
  372. when 'blue' then 'Color.Blue'
  373. when 'black' then 'Color.Black'
  374. when 'white' then 'Color.White'
  375. when 'gray', 'grey' then 'Color.Gray'
  376. when 'yellow' then 'Color.Yellow'
  377. when 'cyan' then 'Color.Cyan'
  378. when 'magenta' then 'Color.Magenta'
  379. else 'Color.Unspecified'
  380. end
  381. else
  382. 2 'Color.Unspecified'
  383. end
  384. when 'CollectionDataSource'
  385. # Return the actual default value string or create new instance
  386. 1 if value.is_a?(String) && value == 'CollectionDataSource()'
  387. 1 'com.kotlinjsonui.data.CollectionDataSource()'
  388. else
  389. 'com.kotlinjsonui.data.CollectionDataSource()'
  390. end
  391. when /^List<.*>$/
  392. # Handle generic List types
  393. 2 if value.is_a?(Array) && value.empty?
  394. 1 'emptyList()'
  395. 1 elsif value == '[]' || value == []
  396. 1 'emptyList()'
  397. else
  398. 'emptyList()'
  399. end
  400. when /^Map<.*>$/
  401. # Handle generic Map types
  402. 2 if value.is_a?(Hash) && value.empty?
  403. 1 'emptyMap()'
  404. 1 elsif value == '{}' || value == {} || value == '{}'
  405. 1 'emptyMap()'
  406. else
  407. 'emptyMap()'
  408. end
  409. else
  410. # For all other cases, use value as-is
  411. value
  412. end
  413. end
  414. 1 def to_pascal_case(str)
  415. # Handle various naming patterns
  416. 14 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  417. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  418. .downcase
  419. 14 snake.split(/[_\-]/).map(&:capitalize).join
  420. end
  421. end
  422. end
  423. end

lib/compose/generators/cell_generator.rb

96.81% lines covered

94 relevant lines. 91 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class CellGenerator
  10. 1 def initialize(name, options = {})
  11. 16 @name = name
  12. 16 @options = options
  13. 16 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 14 parts = @name.split('/')
  18. 14 cell_name = parts.last
  19. 14 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Convert subdirectory to snake_case for JSON layouts
  21. 18 snake_subdirectory = parts[0...-1].map { |p| to_snake_case(p) }.join('/') if parts.length > 1
  22. # Keep original PascalCase if provided, otherwise convert
  23. # If the name is already in PascalCase (e.g., ProductCell), keep it
  24. 14 cell_class_name = cell_name
  25. 14 json_file_name = to_snake_case(cell_name)
  26. # Get directories from config
  27. 14 source_dir = @config['source_directory'] || 'src/main'
  28. 14 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  29. 14 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  30. 14 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  31. 14 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  32. 14 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  33. # Create full paths with subdirectory support
  34. # Each cell gets its own directory (using snake_case for Android)
  35. 14 cell_folder_name = to_snake_case(cell_name)
  36. 14 if subdirectory
  37. # JSON uses snake_case subdirectory, view/viewmodel/data use original casing
  38. 3 json_path = File.join(source_dir, layouts_dir, snake_subdirectory)
  39. 3 swift_path = File.join(source_dir, view_dir, subdirectory, cell_folder_name)
  40. 3 viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
  41. 3 data_path = File.join(source_dir, data_dir, subdirectory)
  42. else
  43. 11 json_path = File.join(source_dir, layouts_dir)
  44. 11 swift_path = File.join(source_dir, view_dir, cell_folder_name)
  45. 11 viewmodel_path = File.join(source_dir, viewmodel_dir)
  46. 11 data_path = File.join(source_dir, data_dir)
  47. end
  48. # Create directories if they don't exist
  49. 14 FileUtils.mkdir_p(json_path)
  50. 14 FileUtils.mkdir_p(swift_path)
  51. 14 FileUtils.mkdir_p(viewmodel_path)
  52. 14 FileUtils.mkdir_p(data_path)
  53. # Create JSON file
  54. 14 json_file = File.join(json_path, "#{json_file_name}.json")
  55. 14 create_json_template(json_file, cell_class_name)
  56. # Create Main Cell View file (add View suffix to class name)
  57. 14 main_kotlin_file = File.join(swift_path, "#{cell_class_name}View.kt")
  58. 14 create_main_cell_template(main_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  59. # Create Generated View file
  60. 14 generated_kotlin_file = File.join(swift_path, "#{cell_class_name}GeneratedView.kt")
  61. 14 create_generated_cell_template(generated_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  62. # Create Data file with item property
  63. 14 data_file = File.join(data_path, "#{cell_class_name}Data.kt")
  64. 14 create_cell_data_template(data_file, cell_class_name, package_name)
  65. # Create ViewModel file
  66. 14 viewmodel_file = File.join(viewmodel_path, "#{cell_class_name}ViewModel.kt")
  67. 14 create_cell_viewmodel_template(viewmodel_file, cell_class_name, json_file_name, subdirectory, package_name)
  68. 14 puts "Generated Collection Cell view:"
  69. 14 puts " JSON: #{json_file}"
  70. 14 puts " Main View: #{main_kotlin_file}"
  71. 14 puts " Generated View: #{generated_kotlin_file}"
  72. 14 puts " Data: #{data_file}"
  73. 14 puts " ViewModel: #{viewmodel_file}"
  74. 14 puts ""
  75. 14 puts "Next steps:"
  76. 14 puts " 1. Edit the JSON layout in #{json_file}"
  77. 14 puts " 2. Run 'kjui build' to generate the Compose code"
  78. 14 puts " 3. Use this cell in Collection components with cellClasses: [\"#{cell_class_name}\"]"
  79. end
  80. 1 private
  81. 1 def create_json_template(file_path, class_name)
  82. 14 return if File.exist?(file_path)
  83. json_content = {
  84. 13 "type" => "View",
  85. "orientation" => "horizontal",
  86. "padding" => 12,
  87. "background" => "#F9F9F9",
  88. "cornerRadius" => 6,
  89. "child" => [
  90. {
  91. "type" => "Text",
  92. "text" => "@{item.title}",
  93. "fontSize" => 14,
  94. "weight" => 1
  95. },
  96. {
  97. "type" => "Text",
  98. "text" => "@{item.value}",
  99. "fontSize" => 14,
  100. "fontWeight" => "bold"
  101. }
  102. ]
  103. }
  104. 13 File.write(file_path, JSON.pretty_generate(json_content))
  105. end
  106. 1 def create_main_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  107. 14 return if File.exist?(file_path)
  108. # Calculate relative package path
  109. 14 view_package = if subdirectory
  110. 3 "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
  111. else
  112. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  113. end
  114. 14 content = <<~KOTLIN
  115. package #{view_package}
  116. import androidx.compose.runtime.Composable
  117. import androidx.compose.ui.Modifier
  118. import #{package_name}.data.#{class_name}Data
  119. @Composable
  120. fun #{class_name}View(
  121. data: #{class_name}Data,
  122. modifier: Modifier = Modifier
  123. ) {
  124. // This is a cell view for use in Collection components
  125. // The data parameter contains an 'item' property with the cell's data
  126. #{class_name}GeneratedView(
  127. data = data,
  128. modifier = modifier
  129. )
  130. }
  131. KOTLIN
  132. 14 File.write(file_path, content)
  133. end
  134. 1 def create_generated_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  135. 14 return if File.exist?(file_path)
  136. # Calculate relative package path
  137. 14 view_package = if subdirectory
  138. 3 "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
  139. else
  140. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  141. end
  142. 14 content = <<~KOTLIN
  143. package #{view_package}
  144. import androidx.compose.foundation.background
  145. import androidx.compose.foundation.layout.*
  146. import androidx.compose.material3.*
  147. import androidx.compose.runtime.Composable
  148. import androidx.compose.ui.Alignment
  149. import androidx.compose.ui.Modifier
  150. import androidx.compose.ui.graphics.Color
  151. import androidx.compose.ui.text.font.FontWeight
  152. import androidx.compose.ui.text.style.TextAlign
  153. import androidx.compose.ui.unit.dp
  154. import androidx.compose.ui.unit.sp
  155. import #{package_name}.data.#{class_name}Data
  156. import androidx.compose.material3.CircularProgressIndicator
  157. import androidx.compose.foundation.layout.Box
  158. import com.kotlinjsonui.core.DynamicModeManager
  159. import com.kotlinjsonui.core.SafeDynamicView
  160. @Composable
  161. fun #{class_name}GeneratedView(
  162. data: #{class_name}Data,
  163. modifier: Modifier = Modifier
  164. ) {
  165. // Generated Compose code from #{json_name}.json
  166. // This will be updated when you run 'kjui build'
  167. // >>> GENERATED_CODE_START
  168. // Check if Dynamic Mode is active
  169. if (DynamicModeManager.isActive()) {
  170. // Dynamic Mode - use SafeDynamicView for real-time updates
  171. SafeDynamicView(
  172. layoutName = "#{json_name}",
  173. data = data.toMap(),
  174. modifier = modifier,
  175. fallback = {
  176. // Show error or loading state when dynamic view is not available
  177. Box(
  178. modifier = Modifier.fillMaxSize(),
  179. contentAlignment = Alignment.Center
  180. ) {
  181. Text(
  182. text = "Dynamic view not available",
  183. color = Color.Gray
  184. )
  185. }
  186. },
  187. onError = { error ->
  188. // Log error or show error UI
  189. android.util.Log.e("DynamicView", "Error loading #{json_name}: \\$error")
  190. },
  191. onLoading = {
  192. // Show loading indicator
  193. Box(
  194. modifier = Modifier.fillMaxSize(),
  195. contentAlignment = Alignment.Center
  196. ) {
  197. CircularProgressIndicator()
  198. }
  199. }
  200. ) { jsonContent ->
  201. // Parse and render the dynamic JSON content
  202. // This will be handled by the DynamicView implementation
  203. }
  204. } else {
  205. // Static Mode - use generated code
  206. // TODO: Generated content will appear here when you run 'kjui build'
  207. Box(
  208. modifier = modifier
  209. .fillMaxWidth()
  210. .padding(16.dp)
  211. ) {
  212. Text("Cell content will be generated from #{json_name}.json")
  213. }
  214. }
  215. // >>> GENERATED_CODE_END
  216. }
  217. KOTLIN
  218. 14 File.write(file_path, content)
  219. end
  220. 1 def create_cell_data_template(file_path, class_name, package_name)
  221. 14 return if File.exist?(file_path)
  222. 14 content = <<~KOTLIN
  223. package #{package_name}.data
  224. data class #{class_name}Data(
  225. var item: Map<String, Any> = emptyMap()
  226. ) {
  227. companion object {
  228. // Update properties from map
  229. fun fromMap(map: Map<String, Any>): #{class_name}Data {
  230. return #{class_name}Data(
  231. item = map["item"] as? Map<String, Any> ?: emptyMap()
  232. )
  233. }
  234. }
  235. // Convert properties to map for runtime use
  236. fun toMap(): MutableMap<String, Any> {
  237. val map = mutableMapOf<String, Any>()
  238. // Data properties
  239. map["item"] = item
  240. return map
  241. }
  242. }
  243. KOTLIN
  244. 14 File.write(file_path, content)
  245. end
  246. 1 def create_cell_viewmodel_template(file_path, class_name, json_name, subdirectory, package_name)
  247. 14 return if File.exist?(file_path)
  248. 14 content = <<~KOTLIN
  249. package #{package_name}.viewmodels
  250. import android.app.Application
  251. import androidx.lifecycle.AndroidViewModel
  252. import androidx.lifecycle.viewModelScope
  253. import androidx.compose.runtime.mutableStateOf
  254. import androidx.compose.runtime.getValue
  255. import androidx.compose.runtime.setValue
  256. import kotlinx.coroutines.launch
  257. import #{package_name}.data.#{class_name}Data
  258. class #{class_name}ViewModel(application: Application) : AndroidViewModel(application) {
  259. // Cell data - managed by parent Collection
  260. var data by mutableStateOf(#{class_name}Data())
  261. private set
  262. // This is a cell view model
  263. // Data is typically provided by the parent Collection component
  264. fun updateData(newData: #{class_name}Data) {
  265. data = newData
  266. }
  267. fun updateItem(item: Map<String, Any>) {
  268. data = data.copy(item = item)
  269. }
  270. }
  271. KOTLIN
  272. 14 File.write(file_path, content)
  273. end
  274. 1 def to_pascal_case(str)
  275. str.split(/[_\-]/).map(&:capitalize).join
  276. end
  277. 1 def to_snake_case(str)
  278. 60 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  279. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  280. .downcase
  281. end
  282. 1 def to_camel_case(str)
  283. pascal = to_pascal_case(str)
  284. pascal[0].downcase + pascal[1..-1]
  285. end
  286. end
  287. end
  288. end
  289. end

lib/compose/generators/converter_generator.rb

82.43% lines covered

148 relevant lines. 122 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/logger'
  5. 1 require_relative 'kotlin_component_generator'
  6. 1 require_relative 'dynamic_component_generator'
  7. 1 module KjuiTools
  8. 1 module Compose
  9. 1 module Generators
  10. 1 class ConverterGenerator
  11. 1 def initialize(name, options = {})
  12. 32 @name = name
  13. # Keep original PascalCase name for component
  14. 32 @component_pascal_case = name # e.g., MyTestCard
  15. 32 @component_snake_case = to_snake_case(name) # e.g., my_test_card
  16. 32 @class_name = name + "Component" # e.g., MyTestCardComponent
  17. 32 @options = options
  18. 32 @logger = Core::Logger
  19. end
  20. 1 def generate
  21. 5 @logger.info "Generating custom converter: #{@class_name}"
  22. # Create converter file for static generation
  23. 5 create_converter_file
  24. # Update component mappings file
  25. 5 update_mappings_file
  26. # Create Kotlin component file using separate generator
  27. 5 kotlin_generator = KotlinComponentGenerator.new(@name, @options)
  28. 5 kotlin_generator.generate
  29. # Generate dynamic component file
  30. 5 dynamic_generator = DynamicComponentGenerator.new(@name, @options)
  31. 5 dynamic_generator.generate
  32. # Create or update DynamicComponentInitializer files
  33. 5 create_dynamic_initializers
  34. # Generate attribute definition file
  35. 5 generate_attribute_definition_file
  36. 5 @logger.success "Successfully generated converter: #{@class_name}"
  37. 5 @logger.info "Converter file created at: kjui_tools/lib/compose/components/extensions/#{@component_snake_case}_component.rb"
  38. 5 @logger.info "Mappings file updated with '#{@component_pascal_case}' => '#{@class_name}'"
  39. end
  40. 1 private
  41. 1 def create_converter_file
  42. # Get the path relative to this generator file
  43. 5 generator_dir = File.dirname(__FILE__)
  44. # Go up to lib/compose/components/extensions
  45. 5 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  46. 5 extensions_dir = File.expand_path(extensions_dir)
  47. 5 FileUtils.mkdir_p(extensions_dir)
  48. 5 file_path = File.join(extensions_dir, "#{@component_snake_case}_component.rb")
  49. 5 if File.exist?(file_path)
  50. @logger.warn "Converter file already exists: #{file_path}"
  51. print "Overwrite? (y/n): "
  52. response = gets.chomp.downcase
  53. return unless response == 'y'
  54. end
  55. 5 File.write(file_path, converter_template)
  56. 5 @logger.info "Created converter file: #{file_path}"
  57. end
  58. 1 def update_mappings_file
  59. # Get the path relative to this generator file
  60. 5 generator_dir = File.dirname(__FILE__)
  61. 5 mappings_file = File.join(generator_dir, '..', 'components', 'extensions', 'component_mappings.rb')
  62. 5 mappings_file = File.expand_path(mappings_file)
  63. # Create new mappings file if it doesn't exist
  64. 5 if !File.exist?(mappings_file)
  65. 5 create_initial_mappings_file
  66. 5 return
  67. end
  68. # Read existing mappings
  69. content = File.read(mappings_file)
  70. # Check if mapping already exists
  71. if content.include?("'#{@component_pascal_case}' =>")
  72. @logger.warn "Mapping for '#{@component_pascal_case}' already exists in component_mappings.rb"
  73. return
  74. end
  75. # Add require statement if not present
  76. require_line = "require_relative '#{@component_snake_case}_component'"
  77. unless content.include?(require_line)
  78. # Add require after other requires or at the beginning of the module
  79. if content =~ /^require_relative/
  80. # Add after the last require
  81. content.sub!(/^((?:require_relative.*\n)+)/) do
  82. "#{$1}#{require_line}\n"
  83. end
  84. else
  85. # Add before the module declaration
  86. content.sub!(/^(# Auto-generated.*\n)\n/) do
  87. "#{$1}\n#{require_line}\n\n"
  88. end
  89. end
  90. end
  91. # Add new mapping
  92. new_mapping = " '#{@component_pascal_case}' => #{@class_name},"
  93. # Insert the new mapping before the closing brace of COMPONENT_MAPPINGS
  94. content.sub!(/(COMPONENT_MAPPINGS = \{.*?)(,?)(\s*)( \}\.freeze)/m) do
  95. existing_mappings = $1
  96. last_comma = $2
  97. whitespace = $3
  98. closing = $4
  99. # If there are existing mappings, add the new one with proper formatting
  100. if existing_mappings =~ /=>/
  101. # Ensure the last existing mapping has a comma, then add the new mapping
  102. "#{existing_mappings},\n#{new_mapping}\n#{closing}"
  103. else
  104. # First mapping
  105. "#{existing_mappings}\n#{new_mapping}\n#{closing}"
  106. end
  107. end
  108. File.write(mappings_file, content)
  109. @logger.info "Updated component_mappings.rb with new mapping"
  110. end
  111. 1 def create_initial_mappings_file
  112. # Get the path relative to this generator file
  113. 6 generator_dir = File.dirname(__FILE__)
  114. 6 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  115. 6 extensions_dir = File.expand_path(extensions_dir)
  116. 6 FileUtils.mkdir_p(extensions_dir)
  117. 6 mappings_file = File.join(extensions_dir, 'component_mappings.rb')
  118. 6 content = <<~RUBY
  119. # frozen_string_literal: true
  120. # This file maps custom component types to their converter classes
  121. # Auto-generated by kjui g converter command
  122. require_relative '#{@component_snake_case}_component'
  123. module KjuiTools
  124. module Compose
  125. module Components
  126. module Extensions
  127. COMPONENT_MAPPINGS = {
  128. '#{@component_pascal_case}' => #{@class_name},
  129. }.freeze
  130. end
  131. end
  132. end
  133. end
  134. RUBY
  135. 6 File.write(mappings_file, content)
  136. 6 @logger.info "Created component_mappings.rb with initial mapping"
  137. end
  138. 1 def converter_template
  139. 9 <<~RUBY
  140. # frozen_string_literal: true
  141. require_relative '../../helpers/modifier_builder'
  142. module KjuiTools
  143. module Compose
  144. module Components
  145. module Extensions
  146. class #{@class_name}
  147. def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  148. required_imports&.add(:box)
  149. # Check if this is a container component
  150. children = json_data['children'] || json_data['child']
  151. is_container = children && children.is_a?(Array) && !children.empty?
  152. # Collect parameters
  153. params = []
  154. # Helper method to format values
  155. format_value = lambda do |value, type|
  156. case type.downcase
  157. when 'string', 'text'
  158. # Use ResourceResolver to process strings (checks for resources)
  159. Helpers::ResourceResolver.process_text(value, required_imports)
  160. when 'int', 'integer', 'float', 'double', 'bool', 'boolean'
  161. value.to_s
  162. when 'color'
  163. # Use ResourceResolver to process colors
  164. Helpers::ResourceResolver.process_color(value, required_imports)
  165. else
  166. value.to_s
  167. end
  168. end
  169. #{generate_parameter_collection}
  170. # Build modifiers
  171. modifiers = []
  172. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  173. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  174. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  175. modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  176. if is_container
  177. # Container component with children
  178. code = indent("#{@component_pascal_case}(", depth)
  179. if !params.empty?
  180. params.each_with_index do |param, index|
  181. separator = index == params.length - 1 ? '' : ','
  182. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  183. end
  184. end
  185. if !modifiers.empty?
  186. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  187. code += (params.empty? ? modifier_str : "," + modifier_str)
  188. end
  189. code += "\\n" + indent(") {", depth)
  190. # Process children - return with metadata for ComposeBuilder to handle
  191. return {
  192. code: code,
  193. children: children,
  194. closing: "\\n" + indent("}", depth)
  195. }
  196. else
  197. # Non-container component
  198. if params.empty? && modifiers.empty?
  199. code = indent("#{@component_pascal_case}()", depth)
  200. else
  201. code = indent("#{@component_pascal_case}(", depth)
  202. if !params.empty?
  203. params.each_with_index do |param, index|
  204. separator = index == params.length - 1 ? '' : ','
  205. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  206. end
  207. end
  208. if !modifiers.empty?
  209. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  210. code += (params.empty? ? modifier_str : "," + modifier_str)
  211. end
  212. code += "\\n" + indent(")", depth)
  213. end
  214. end
  215. code
  216. end
  217. private
  218. def self.indent(text, level)
  219. return text if level == 0
  220. spaces = ' ' * level
  221. text.split("\\n").map { |line|
  222. line.empty? ? line : spaces + line
  223. }.join("\\n")
  224. end
  225. end
  226. end
  227. end
  228. end
  229. end
  230. RUBY
  231. end
  232. 1 def generate_parameter_collection
  233. 13 return "" if !@options[:attributes] || @options[:attributes].empty?
  234. 7 lines = []
  235. 7 @options[:attributes].each do |key, type|
  236. # Check if this is a binding property (starts with @)
  237. 14 is_binding = key.start_with?('@')
  238. 14 actual_key = is_binding ? key[1..-1] : key
  239. 14 lines << " if json_data['#{actual_key}']"
  240. 14 lines << " value = json_data['#{actual_key}']"
  241. 14 lines << " if value.is_a?(String) && value.match?(/@\\{([^}]+)\\}/)"
  242. 14 lines << " # Handle binding"
  243. 14 lines << " prop_name = value[2..-2]"
  244. 14 lines << " params << \"#{actual_key} = data.\#{prop_name}\""
  245. 14 lines << " else"
  246. 14 lines << " # Handle static value"
  247. 14 lines << " formatted_value = format_value.call(value, '#{type}')"
  248. 14 lines << " params << \"#{actual_key} = \#{formatted_value}\" if formatted_value"
  249. 14 lines << " end"
  250. 14 lines << " end"
  251. end
  252. 7 lines.join("\n")
  253. end
  254. 1 def to_snake_case(str)
  255. 36 str.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  256. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  257. .downcase
  258. end
  259. 1 def create_dynamic_initializers
  260. 5 config = Core::ConfigManager.load_config
  261. 5 base_path = config['_config_dir'] || Dir.pwd
  262. 5 source_directory = config['source_directory'] || 'src/main'
  263. 5 package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  264. # Create debug version
  265. 5 debug_dir = File.join(
  266. base_path,
  267. source_directory.gsub('main', 'debug'),
  268. 'kotlin',
  269. package_name.gsub('.', '/')
  270. )
  271. 5 FileUtils.mkdir_p(debug_dir)
  272. 5 debug_file = File.join(debug_dir, 'DynamicComponentInitializer.kt')
  273. # Only create if it doesn't exist yet
  274. 5 if !File.exist?(debug_file)
  275. 5 File.write(debug_file, generate_debug_initializer_content(package_name))
  276. 5 @logger.info "Created DynamicComponentInitializer (debug)"
  277. end
  278. # Create release version
  279. 5 release_dir = File.join(
  280. base_path,
  281. source_directory.gsub('main', 'release'),
  282. 'kotlin',
  283. package_name.gsub('.', '/')
  284. )
  285. 5 FileUtils.mkdir_p(release_dir)
  286. 5 release_file = File.join(release_dir, 'DynamicComponentInitializer.kt')
  287. # Only create if it doesn't exist yet
  288. 5 if !File.exist?(release_file)
  289. 5 File.write(release_file, generate_release_initializer_content(package_name))
  290. 5 @logger.info "Created DynamicComponentInitializer (release)"
  291. end
  292. end
  293. 1 def generate_debug_initializer_content(package_name)
  294. 6 <<~KOTLIN
  295. package #{package_name}
  296. import androidx.compose.runtime.Composable
  297. import com.google.gson.JsonObject
  298. import com.kotlinjsonui.core.Configuration
  299. import #{package_name}.dynamic.DynamicComponentRegistry
  300. /**
  301. * Debug-only initializer for custom components in dynamic mode
  302. * Auto-generated by kjui converter generator
  303. */
  304. object DynamicComponentInitializer {
  305. /**
  306. * Register custom component handler for dynamic mode
  307. * This is only available in debug builds where DynamicComponentRegistry exists
  308. */
  309. fun initialize() {
  310. Configuration.customComponentHandler = { type, json, data ->
  311. DynamicComponentRegistry.createCustomComponent(type, json, data)
  312. }
  313. }
  314. }
  315. KOTLIN
  316. end
  317. 1 def generate_release_initializer_content(package_name)
  318. 6 <<~KOTLIN
  319. package #{package_name}
  320. /**
  321. * Release version of DynamicComponentInitializer (no-op)
  322. * Auto-generated by kjui converter generator
  323. */
  324. object DynamicComponentInitializer {
  325. /**
  326. * No-op in release builds
  327. */
  328. fun initialize() {
  329. // Dynamic component registry is not available in release builds
  330. }
  331. }
  332. KOTLIN
  333. end
  334. # Generate attribute definition file for validation
  335. 1 def generate_attribute_definition_file
  336. # Skip if no attributes defined
  337. 5 return if !@options[:attributes] || @options[:attributes].empty?
  338. # Get the path relative to this generator file
  339. 4 generator_dir = File.dirname(__FILE__)
  340. 4 definitions_dir = File.join(generator_dir, '..', 'components', 'extensions', 'attribute_definitions')
  341. 4 definitions_dir = File.expand_path(definitions_dir)
  342. 4 FileUtils.mkdir_p(definitions_dir)
  343. 4 file_path = File.join(definitions_dir, "#{@component_pascal_case}.json")
  344. # Build attribute definitions
  345. 4 attribute_defs = {}
  346. 4 @options[:attributes].each do |key, type|
  347. # Remove @ prefix if present (for binding properties)
  348. 9 actual_key = key.start_with?('@') ? key[1..-1] : key
  349. 9 attribute_defs[actual_key] = build_attribute_definition(actual_key, type)
  350. end
  351. # Wrap in component name
  352. definition = {
  353. 4 @component_pascal_case => attribute_defs
  354. }
  355. # Write JSON file
  356. 4 File.write(file_path, JSON.pretty_generate(definition))
  357. 4 @logger.info "Created attribute definition file: #{file_path}"
  358. end
  359. # Map type string to JSON schema type (supports binding for all types)
  360. # @param type [String] The type string from options
  361. # @return [Array, String] JSON schema type(s) - array for binding support
  362. 1 def map_type_to_json_type(type)
  363. 19 case type.downcase
  364. when 'string'
  365. 5 ['string', 'binding']
  366. when 'int', 'integer'
  367. 4 ['number', 'binding']
  368. when 'double', 'float'
  369. 2 ['number', 'binding']
  370. when 'bool', 'boolean'
  371. 3 ['boolean', 'binding']
  372. else
  373. # Custom class types must use binding syntax (@{propertyName})
  374. 5 'binding'
  375. end
  376. end
  377. 1 def build_attribute_definition(actual_key, type)
  378. {
  379. 9 "type" => map_type_to_json_type(type),
  380. "description" => "#{actual_key} attribute"
  381. }
  382. end
  383. end
  384. end
  385. end
  386. end

lib/compose/generators/dynamic_component_generator.rb

64.9% lines covered

151 relevant lines. 98 lines covered and 53 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class DynamicComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 40 @name = name
  12. 40 @component_name = name # PascalCase name
  13. 40 @class_name = "Dynamic#{name}Component"
  14. 40 @options = options
  15. 40 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_dynamic_component_file
  19. update_dynamic_registry
  20. end
  21. 1 private
  22. 1 def create_dynamic_component_file
  23. config = Core::ConfigManager.load_config
  24. # Use config directory if available (where kjui.config.json was found)
  25. base_path = config['_config_dir'] || Dir.pwd
  26. source_directory = config['source_directory'] || 'src/main'
  27. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  28. # Create dynamic components directory in debug source set
  29. dynamic_dir = File.join(
  30. base_path,
  31. source_directory.gsub('main', 'debug'), # Replace main with debug
  32. 'kotlin',
  33. package_name.gsub('.', '/'),
  34. 'dynamic/components/extensions'
  35. )
  36. FileUtils.mkdir_p(dynamic_dir)
  37. file_path = File.join(dynamic_dir, "#{@class_name}.kt")
  38. if File.exist?(file_path)
  39. @logger.warn "Dynamic component file already exists: #{file_path}"
  40. print "Overwrite? (y/n): "
  41. response = gets.chomp.downcase
  42. return unless response == 'y'
  43. end
  44. File.write(file_path, dynamic_template)
  45. @logger.info "Created dynamic component file: #{file_path}"
  46. end
  47. 1 def update_dynamic_registry
  48. config = Core::ConfigManager.load_config
  49. # Use config directory if available (where kjui.config.json was found)
  50. base_path = config['_config_dir'] || Dir.pwd
  51. source_directory = config['source_directory'] || 'src/main'
  52. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  53. registry_file = File.join(
  54. base_path,
  55. source_directory.gsub('main', 'debug'), # Replace main with debug
  56. 'kotlin',
  57. package_name.gsub('.', '/'),
  58. 'dynamic/DynamicComponentRegistry.kt'
  59. )
  60. if !File.exist?(registry_file)
  61. create_initial_registry
  62. return
  63. end
  64. # Read existing registry
  65. content = File.read(registry_file)
  66. # Check if component already registered
  67. if content.include?("\"#{@component_name}\"")
  68. @logger.warn "Component '#{@component_name}' already registered in DynamicComponentRegistry"
  69. return
  70. end
  71. # Add new registration with proper indentation
  72. new_registration = <<-REGISTRATION.chomp
  73. "#{@component_name}" -> {
  74. #{@class_name}.create(json, data)
  75. true
  76. }
  77. REGISTRATION
  78. # Insert before the else statement in when block
  79. content.sub!(/(when \(type\) \{.*?)(\n else)/m) do
  80. existing = $1
  81. else_clause = $2
  82. "#{existing}\n#{new_registration}#{else_clause}"
  83. end
  84. # Add import if not present
  85. config = Core::ConfigManager.load_config
  86. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  87. import_line = "import #{package_name}.dynamic.components.extensions.#{@class_name}"
  88. unless content.include?(import_line)
  89. # Add import after the last import line
  90. content.sub!(/(import .+\n)(\n)/) do
  91. "#{$1}#{import_line}\n#{$2}"
  92. end
  93. end
  94. File.write(registry_file, content)
  95. @logger.info "Updated DynamicComponentRegistry with new component"
  96. end
  97. 1 def create_initial_registry
  98. config = Core::ConfigManager.load_config
  99. # Use config directory if available (where kjui.config.json was found)
  100. base_path = config['_config_dir'] || Dir.pwd
  101. source_directory = config['source_directory'] || 'src/main'
  102. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  103. registry_dir = File.join(
  104. base_path,
  105. source_directory.gsub('main', 'debug'), # Replace main with debug
  106. 'kotlin',
  107. package_name.gsub('.', '/'),
  108. 'dynamic'
  109. )
  110. FileUtils.mkdir_p(registry_dir)
  111. registry_file = File.join(registry_dir, 'DynamicComponentRegistry.kt')
  112. config = Core::ConfigManager.load_config
  113. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  114. content = <<~KOTLIN
  115. package #{package_name}.dynamic
  116. import androidx.compose.runtime.Composable
  117. import com.google.gson.JsonObject
  118. import #{package_name}.dynamic.components.extensions.#{@class_name}
  119. /**
  120. * Registry for dynamic custom components
  121. * Auto-generated by kjui converter generator
  122. */
  123. object DynamicComponentRegistry {
  124. @Composable
  125. fun createCustomComponent(
  126. type: String,
  127. json: JsonObject,
  128. data: Map<String, Any>
  129. ): Boolean {
  130. return when (type) {
  131. "#{@component_name}" -> {
  132. #{@class_name}.create(json, data)
  133. true
  134. }
  135. else -> false
  136. }
  137. }
  138. }
  139. KOTLIN
  140. File.write(registry_file, content)
  141. @logger.info "Created DynamicComponentRegistry with initial component"
  142. end
  143. 1 def dynamic_template
  144. 2 config = Core::ConfigManager.load_config
  145. 2 package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  146. # Determine if this is a container component
  147. 2 is_container = @options[:is_container]
  148. 2 <<~KOTLIN
  149. package #{package_name}.dynamic.components.extensions
  150. import androidx.compose.runtime.Composable
  151. import androidx.compose.ui.Modifier
  152. import com.google.gson.JsonObject
  153. import com.google.gson.JsonElement
  154. #{generate_dynamic_imports}
  155. import com.kotlinjsonui.dynamic.helpers.ModifierBuilder
  156. import #{package_name}.extensions.#{@component_name}
  157. /**
  158. * Dynamic wrapper for #{@component_name} component
  159. * Auto-generated by kjui converter generator
  160. */
  161. object #{@class_name} {
  162. @Composable
  163. fun create(
  164. json: JsonObject,
  165. data: Map<String, Any> = emptyMap()
  166. ) {
  167. // Parse attributes
  168. #{generate_dynamic_parameter_parsing}
  169. // Build modifier
  170. val modifier = ModifierBuilder.buildModifier(json)
  171. #{if is_container
  172. 1 "// Call the custom component with children\n" +
  173. " #{@component_name}(\n" +
  174. generate_component_parameters +
  175. " modifier = modifier\n" +
  176. " ) {\n" +
  177. " // Process children\n" +
  178. " val children = json.get(\"children\")?.asJsonArray ?: json.get(\"child\")?.asJsonArray\n" +
  179. " children?.forEach { childJson ->\n" +
  180. " if (childJson.isJsonObject) {\n" +
  181. " com.kotlinjsonui.dynamic.DynamicView(\n" +
  182. " json = childJson.asJsonObject,\n" +
  183. " data = data\n" +
  184. " )\n" +
  185. " }\n" +
  186. " }\n" +
  187. " }"
  188. else
  189. 1 "// Call the custom component\n" +
  190. " #{@component_name}(\n" +
  191. generate_component_parameters +
  192. " modifier = modifier\n" +
  193. " )"
  194. end}
  195. }
  196. #{generate_helper_methods}
  197. }
  198. KOTLIN
  199. end
  200. 1 def generate_dynamic_imports
  201. 6 return "" if !@options[:attributes] || @options[:attributes].empty?
  202. 3 imports = []
  203. 3 @options[:attributes].each do |key, type|
  204. 3 case type.downcase
  205. when 'alignment'
  206. 1 imports << "import androidx.compose.ui.Alignment"
  207. when 'text', 'string'
  208. 1 imports << "import androidx.compose.ui.text.style.TextAlign"
  209. when 'color'
  210. 1 imports << "import androidx.compose.ui.graphics.Color"
  211. end
  212. end
  213. 3 imports.uniq.join("\n")
  214. end
  215. 1 def generate_attribute_docs
  216. 3 return " * - child/children: Array of child components" if !@options[:attributes] || @options[:attributes].empty?
  217. 2 docs = [" * - child/children: Array of child components"]
  218. 2 @options[:attributes].each do |key, type|
  219. 2 is_binding = key.start_with?('@')
  220. 2 actual_key = is_binding ? key[1..-1] : key
  221. 2 binding_note = is_binding ? " (supports @{binding})" : ""
  222. 2 docs << " * - #{actual_key}: #{type}#{binding_note}"
  223. end
  224. 2 docs.join("\n")
  225. end
  226. 1 def generate_dynamic_parameter_parsing
  227. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  228. 1 lines = []
  229. 1 @options[:attributes].each do |key, type|
  230. 1 is_binding = key.start_with?('@')
  231. 1 actual_key = is_binding ? key[1..-1] : key
  232. 1 method_name = get_parser_method_name(type)
  233. 1 lines << " val #{actual_key} = #{method_name}(json.get(\"#{actual_key}\"), data)"
  234. end
  235. 1 lines.join("\n")
  236. end
  237. 1 def get_parser_method_name(type)
  238. 10 case type.downcase
  239. when 'string', 'text'
  240. 3 'parseString'
  241. when 'int', 'integer'
  242. 2 'parseInt'
  243. when 'bool', 'boolean'
  244. 2 'parseBoolean'
  245. when 'color'
  246. 1 'parseColor'
  247. when 'float', 'double'
  248. 1 'parseFloat'
  249. else
  250. 1 'parseString'
  251. end
  252. end
  253. 1 def generate_component_parameters
  254. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  255. 1 lines = []
  256. 1 @options[:attributes].each do |key, type|
  257. 1 is_binding = key.start_with?('@')
  258. 1 actual_key = is_binding ? key[1..-1] : key
  259. # Generate parameter with null safety
  260. 1 lines << " #{actual_key} = #{actual_key} ?: #{get_default_value(type)},"
  261. end
  262. 1 lines.join("\n") + "\n"
  263. end
  264. 1 def get_default_value(type)
  265. 7 case type.downcase
  266. when 'string', 'text'
  267. 2 '""'
  268. when 'int', 'integer'
  269. 1 '0'
  270. when 'bool', 'boolean'
  271. 1 'false'
  272. when 'float', 'double'
  273. 1 '0.0'
  274. when 'color'
  275. 1 'androidx.compose.ui.graphics.Color.Unspecified'
  276. else
  277. 1 'null'
  278. end
  279. end
  280. 1 def generate_helper_methods
  281. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  282. 4 methods = []
  283. 4 types_added = []
  284. 4 @options[:attributes].each do |key, type|
  285. 4 next if types_added.include?(type.downcase)
  286. 4 types_added << type.downcase
  287. 4 case type.downcase
  288. when 'string', 'text'
  289. 1 methods << string_parser_method
  290. when 'int', 'integer'
  291. 1 methods << int_parser_method
  292. when 'bool', 'boolean'
  293. 1 methods << bool_parser_method
  294. when 'color'
  295. 1 methods << color_parser_method
  296. end
  297. end
  298. 4 methods.join("\n\n")
  299. end
  300. 1 def string_parser_method
  301. 1 <<~KOTLIN
  302. private fun parseString(element: com.google.gson.JsonElement?, data: Map<String, Any>): String? {
  303. if (element == null || element.isJsonNull) return null
  304. val value = element.asString
  305. // Check for binding
  306. if (value.startsWith("@{") && value.endsWith("}")) {
  307. val propertyName = value.substring(2, value.length - 1)
  308. return data[propertyName]?.toString()
  309. }
  310. return value
  311. }
  312. KOTLIN
  313. end
  314. 1 def int_parser_method
  315. 1 <<~KOTLIN
  316. private fun parseInt(element: com.google.gson.JsonElement?, data: Map<String, Any>): Int? {
  317. if (element == null || element.isJsonNull) return null
  318. if (element.isJsonPrimitive) {
  319. val primitive = element.asJsonPrimitive
  320. if (primitive.isNumber) {
  321. return primitive.asInt
  322. } else if (primitive.isString) {
  323. val value = primitive.asString
  324. // Check for binding
  325. if (value.startsWith("@{") && value.endsWith("}")) {
  326. val propertyName = value.substring(2, value.length - 1)
  327. return (data[propertyName] as? Number)?.toInt()
  328. }
  329. return value.toIntOrNull()
  330. }
  331. }
  332. return null
  333. }
  334. KOTLIN
  335. end
  336. 1 def bool_parser_method
  337. 1 <<~KOTLIN
  338. private fun parseBoolean(element: com.google.gson.JsonElement?, data: Map<String, Any>): Boolean? {
  339. if (element == null || element.isJsonNull) return null
  340. if (element.isJsonPrimitive) {
  341. val primitive = element.asJsonPrimitive
  342. if (primitive.isBoolean) {
  343. return primitive.asBoolean
  344. } else if (primitive.isString) {
  345. val value = primitive.asString
  346. // Check for binding
  347. if (value.startsWith("@{") && value.endsWith("}")) {
  348. val propertyName = value.substring(2, value.length - 1)
  349. return data[propertyName] as? Boolean
  350. }
  351. return value.toBooleanStrictOrNull()
  352. }
  353. }
  354. return null
  355. }
  356. KOTLIN
  357. end
  358. 1 def color_parser_method
  359. 1 <<~KOTLIN
  360. private fun parseColor(element: com.google.gson.JsonElement?, data: Map<String, Any>): Color? {
  361. if (element == null || element.isJsonNull) return null
  362. if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
  363. val value = element.asString
  364. // Check for binding
  365. if (value.startsWith("@{") && value.endsWith("}")) {
  366. val propertyName = value.substring(2, value.length - 1)
  367. val boundValue = data[propertyName]?.toString()
  368. return boundValue?.let { parseColorString(it) }
  369. }
  370. return parseColorString(value)
  371. }
  372. return null
  373. }
  374. private fun parseColorString(value: String): Color? {
  375. return if (value.startsWith("#")) {
  376. try {
  377. Color(android.graphics.Color.parseColor(value))
  378. } catch (e: Exception) {
  379. null
  380. }
  381. } else {
  382. null
  383. }
  384. }
  385. KOTLIN
  386. end
  387. end
  388. end
  389. end
  390. end

lib/compose/generators/kotlin_component_generator.rb

83.64% lines covered

110 relevant lines. 92 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class KotlinComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 43 @name = name
  12. 43 @component_name = name # PascalCase name
  13. 43 @package_name = get_package_name
  14. 43 @options = options
  15. 43 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_kotlin_file
  19. end
  20. 1 private
  21. 1 def create_kotlin_file
  22. config = Core::ConfigManager.load_config
  23. # Use config directory if available (where kjui.config.json was found)
  24. base_path = config['_config_dir'] || Dir.pwd
  25. source_directory = config['source_directory'] || 'src/main'
  26. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  27. # Get extension directory from config
  28. extension_directory = config['extension_directory'] || "kotlin/#{package_name.gsub('.', '/')}/extensions"
  29. # Build extension directory path
  30. extension_dir = File.join(
  31. base_path,
  32. source_directory,
  33. extension_directory
  34. )
  35. FileUtils.mkdir_p(extension_dir)
  36. kotlin_file_path = File.join(extension_dir, "#{@component_name}.kt")
  37. if File.exist?(kotlin_file_path)
  38. @logger.warn "Kotlin file already exists: #{kotlin_file_path}"
  39. print "Overwrite? (y/n): "
  40. response = gets.chomp.downcase
  41. return unless response == 'y'
  42. end
  43. File.write(kotlin_file_path, kotlin_template)
  44. @logger.info "Created Kotlin file: #{kotlin_file_path}"
  45. end
  46. 1 def get_package_name
  47. 44 config = Core::ConfigManager.load_config
  48. 44 base_package = config['package_name'] || 'com.example.kotlinjsonui.sample'
  49. 44 "#{base_package}.extensions"
  50. end
  51. 1 def kotlin_template
  52. 2 if @options[:is_container] != false
  53. 1 container_template
  54. else
  55. 1 non_container_template
  56. end
  57. end
  58. 1 def container_template
  59. 3 imports = generate_kotlin_imports
  60. 3 params = generate_kotlin_parameters
  61. 3 template = <<~KOTLIN
  62. package #{@package_name}
  63. import androidx.compose.foundation.layout.Box
  64. import androidx.compose.foundation.layout.BoxScope
  65. import androidx.compose.runtime.Composable
  66. import androidx.compose.ui.Modifier
  67. KOTLIN
  68. 3 template += imports + "\n" if !imports.empty?
  69. 3 template += "\n"
  70. 3 template += <<~KOTLIN
  71. /**
  72. * Custom #{@component_name} component
  73. * Generated by kjui converter generator
  74. *
  75. * Regenerate with:
  76. * kjui g converter #{@component_name} --container#{format_attributes_for_command}
  77. */
  78. @Composable
  79. fun #{@component_name}(
  80. KOTLIN
  81. 3 if !params.empty?
  82. template += params
  83. end
  84. 3 template += <<~KOTLIN
  85. modifier: Modifier = Modifier,
  86. content: @Composable BoxScope.() -> Unit
  87. ) {
  88. Box(
  89. modifier = modifier
  90. ) {
  91. // Custom container implementation
  92. content()
  93. }
  94. }
  95. KOTLIN
  96. 3 template
  97. end
  98. 1 def non_container_template
  99. 2 imports = generate_kotlin_imports
  100. 2 params = generate_kotlin_parameters
  101. 2 template = <<~KOTLIN
  102. package #{@package_name}
  103. import androidx.compose.foundation.layout.Box
  104. import androidx.compose.runtime.Composable
  105. import androidx.compose.ui.Modifier
  106. KOTLIN
  107. 2 template += imports + "\n" if !imports.empty?
  108. 2 template += "\n"
  109. 2 template += <<~KOTLIN
  110. /**
  111. * Custom #{@component_name} component
  112. * Generated by kjui converter generator
  113. *
  114. * Regenerate with:
  115. * kjui g converter #{@component_name} --no-container#{format_attributes_for_command}
  116. */
  117. @Composable
  118. fun #{@component_name}(
  119. KOTLIN
  120. 2 if !params.empty?
  121. template += params
  122. end
  123. 2 template += <<~KOTLIN
  124. modifier: Modifier = Modifier
  125. ) {
  126. // TODO: Implement your custom component
  127. Box(modifier = modifier) {
  128. // Component content
  129. }
  130. }
  131. KOTLIN
  132. 2 template
  133. end
  134. 1 def generate_kotlin_imports
  135. 9 return "" if !@options[:attributes] || @options[:attributes].empty?
  136. 3 imports = []
  137. 3 @options[:attributes].each do |key, type|
  138. 3 case type.downcase
  139. when 'color'
  140. 1 imports << "import androidx.compose.ui.graphics.Color"
  141. when 'dp', 'size'
  142. 1 imports << "import androidx.compose.ui.unit.dp"
  143. 1 imports << "import androidx.compose.ui.unit.Dp"
  144. when 'alignment'
  145. 1 imports << "import androidx.compose.ui.Alignment"
  146. when 'text', 'string'
  147. # No special import needed
  148. when 'int', 'float', 'double'
  149. # No special import needed
  150. when 'boolean', 'bool'
  151. # No special import needed
  152. end
  153. end
  154. 3 imports.uniq.join("\n")
  155. end
  156. 1 def generate_kotlin_parameters
  157. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  158. 1 params = []
  159. 1 @options[:attributes].each do |key, type|
  160. 1 is_binding = key.start_with?('@')
  161. 1 actual_key = is_binding ? key[1..-1] : key
  162. 1 kotlin_type = map_type_to_kotlin(type)
  163. 1 default_value = get_default_value(type)
  164. 1 params << " #{actual_key}: #{kotlin_type}#{default_value},"
  165. end
  166. 1 params.join("\n") + "\n"
  167. end
  168. 1 def map_type_to_kotlin(type)
  169. 14 case type.downcase
  170. when 'string', 'text'
  171. 3 'String'
  172. when 'int', 'integer'
  173. 2 'Int'
  174. when 'float'
  175. 1 'Float'
  176. when 'double'
  177. 1 'Double'
  178. when 'bool', 'boolean'
  179. 2 'Boolean'
  180. when 'color'
  181. 1 'Color'
  182. when 'dp', 'size'
  183. 2 'Dp'
  184. when 'alignment'
  185. 1 'Alignment'
  186. else
  187. 1 'Any'
  188. end
  189. end
  190. 1 def get_default_value(type)
  191. 10 case type.downcase
  192. when 'string', 'text'
  193. 2 ' = ""'
  194. when 'int', 'integer'
  195. 1 ' = 0'
  196. when 'float'
  197. 1 ' = 0f'
  198. when 'double'
  199. 1 ' = 0.0'
  200. when 'bool', 'boolean'
  201. 1 ' = false'
  202. when 'color'
  203. 1 ' = Color.Unspecified'
  204. when 'dp', 'size'
  205. 1 ' = 0.dp'
  206. when 'alignment'
  207. 1 ' = Alignment.TopStart'
  208. else
  209. 1 ' = null'
  210. end
  211. end
  212. 1 def format_attributes_for_command
  213. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  214. 1 attrs = @options[:attributes].map do |key, type|
  215. 2 " --attr #{key}:#{type}"
  216. end.join("")
  217. 1 attrs
  218. end
  219. end
  220. end
  221. end
  222. end

lib/compose/generators/view_generator.rb

74.81% lines covered

135 relevant lines. 101 lines covered and 34 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class ViewGenerator
  10. 1 def initialize(name, options = {})
  11. 19 @name = name
  12. 19 @options = options
  13. 19 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 17 parts = @name.split('/')
  18. 17 view_name = parts.last
  19. 17 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Convert to proper case
  21. 17 view_class_name = to_pascal_case(view_name)
  22. 17 json_file_name = to_snake_case(view_name)
  23. # Get directories from config
  24. 17 source_dir = @config['source_directory'] || 'src/main'
  25. 17 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  26. 17 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  27. 17 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  28. 17 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  29. 17 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  30. # Create full paths with subdirectory support
  31. # Each view gets its own directory (using snake_case for Android)
  32. 17 view_folder_name = to_snake_case(view_name)
  33. 17 if subdirectory
  34. 1 json_path = File.join(source_dir, layouts_dir, subdirectory)
  35. 1 swift_path = File.join(source_dir, view_dir, subdirectory, view_folder_name)
  36. 1 viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
  37. 1 data_path = File.join(source_dir, data_dir, subdirectory)
  38. else
  39. 16 json_path = File.join(source_dir, layouts_dir)
  40. # Create a folder for each view (e.g., views/home_view/ for HomeView)
  41. 16 swift_path = File.join(source_dir, view_dir, view_folder_name)
  42. 16 viewmodel_path = File.join(source_dir, viewmodel_dir)
  43. 16 data_path = File.join(source_dir, data_dir)
  44. end
  45. # Create directories if they don't exist
  46. 17 FileUtils.mkdir_p(json_path)
  47. 17 FileUtils.mkdir_p(swift_path)
  48. 17 FileUtils.mkdir_p(viewmodel_path)
  49. 17 FileUtils.mkdir_p(data_path)
  50. # Create JSON file
  51. 17 json_file = File.join(json_path, "#{json_file_name}.json")
  52. 17 create_json_template(json_file, view_class_name)
  53. # Create Main View file (add View suffix to class name)
  54. 17 main_kotlin_file = File.join(swift_path, "#{view_class_name}View.kt")
  55. 17 create_main_view_template(main_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  56. # Create Generated View file
  57. 17 generated_kotlin_file = File.join(swift_path, "#{view_class_name}GeneratedView.kt")
  58. 17 create_generated_view_template(generated_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  59. # Create Data file
  60. 17 data_file = File.join(data_path, "#{view_class_name}Data.kt")
  61. 17 create_data_template(data_file, view_class_name, package_name)
  62. # Create ViewModel file
  63. 17 viewmodel_file = File.join(viewmodel_path, "#{view_class_name}ViewModel.kt")
  64. 17 create_viewmodel_template(viewmodel_file, view_class_name, json_file_name, subdirectory, package_name)
  65. # Update MainActivity if --root option is specified
  66. 17 if @options[:root]
  67. update_main_activity(view_class_name, package_name)
  68. end
  69. 17 puts "Generated Compose view:"
  70. 17 puts " JSON: #{json_file}"
  71. 17 puts " Main View: #{main_kotlin_file}"
  72. 17 puts " Generated View: #{generated_kotlin_file}"
  73. 17 puts " Data: #{data_file}"
  74. 17 puts " ViewModel: #{viewmodel_file}"
  75. 17 if @options[:root]
  76. puts " Updated MainActivity to use #{view_class_name}View as root"
  77. end
  78. 17 puts ""
  79. 17 puts "Next steps:"
  80. 17 puts " 1. Edit the JSON layout in #{json_file}"
  81. 17 puts " 2. Run 'kjui build' to generate the Compose code"
  82. end
  83. 1 private
  84. 1 def to_pascal_case(str)
  85. # Handle camelCase and PascalCase input
  86. # First convert to snake_case, then to PascalCase
  87. 17 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  88. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  89. .downcase
  90. 17 snake.split(/[_\-]/).map(&:capitalize).join
  91. end
  92. 1 def to_snake_case(str)
  93. 68 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  94. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  95. .downcase
  96. end
  97. 1 def create_json_template(file_path, view_name)
  98. 17 return if File.exist?(file_path)
  99. template = {
  100. 16 type: "SafeAreaView",
  101. background: "#FFFFFF",
  102. child: [
  103. {
  104. type: "View",
  105. orientation: "vertical",
  106. padding: 16,
  107. child: [
  108. {
  109. type: "Label",
  110. text: "@{title}",
  111. fontSize: 24,
  112. fontWeight: "bold",
  113. fontColor: "#000000",
  114. marginBottom: 20
  115. },
  116. {
  117. type: "Label",
  118. text: "Welcome to #{view_name}",
  119. fontSize: 16,
  120. fontColor: "#666666",
  121. marginBottom: 30
  122. },
  123. {
  124. type: "Button",
  125. text: "Get Started",
  126. onclick: "onGetStarted",
  127. background: "#6200EE",
  128. fontColor: "#FFFFFF",
  129. padding: [12, 24],
  130. cornerRadius: 8
  131. }
  132. ]
  133. }
  134. ],
  135. data: [
  136. {
  137. name: "title",
  138. class: "String",
  139. defaultValue: "'#{view_name}'"
  140. }
  141. ]
  142. }
  143. 16 File.write(file_path, JSON.pretty_generate(template))
  144. 16 puts "Created JSON template: #{file_path}"
  145. end
  146. 1 def create_main_view_template(file_path, view_name, json_name, subdirectory, package_name)
  147. 17 return if File.exist?(file_path)
  148. 17 package_parts = package_name.split('.')
  149. # Each view has its own package (e.g., com.example.views.home_view)
  150. 17 view_folder_name = to_snake_case(view_name)
  151. 17 view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  152. 17 template = <<~KOTLIN
  153. package #{view_package}
  154. import androidx.compose.runtime.Composable
  155. import androidx.compose.runtime.collectAsState
  156. import androidx.compose.runtime.getValue
  157. import androidx.lifecycle.viewmodel.compose.viewModel
  158. import #{package_name}.viewmodels.#{view_name}ViewModel
  159. @Composable
  160. fun #{view_name}View(
  161. viewModel: #{view_name}ViewModel = viewModel()
  162. ) {
  163. val data by viewModel.data.collectAsState()
  164. #{view_name}GeneratedView(data = data, viewModel = viewModel)
  165. }
  166. KOTLIN
  167. 17 File.write(file_path, template)
  168. 17 puts "Created Main View template: #{file_path}"
  169. end
  170. 1 def create_generated_view_template(file_path, view_name, json_name, subdirectory, package_name)
  171. 17 return if File.exist?(file_path)
  172. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  173. # Each view has its own package (using snake_case for folder)
  174. 17 view_folder_name = to_snake_case(view_name)
  175. 17 view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  176. 17 template = <<~KOTLIN
  177. package #{view_package}
  178. import androidx.compose.foundation.background
  179. import androidx.compose.foundation.layout.*
  180. import androidx.compose.foundation.lazy.LazyColumn
  181. import androidx.compose.foundation.lazy.LazyRow
  182. import androidx.compose.material3.*
  183. import androidx.compose.runtime.Composable
  184. import androidx.compose.ui.Alignment
  185. import androidx.compose.ui.Modifier
  186. import androidx.compose.ui.graphics.Color
  187. import androidx.compose.ui.text.font.FontWeight
  188. import androidx.compose.ui.text.style.TextAlign
  189. import androidx.compose.ui.unit.dp
  190. import androidx.compose.ui.unit.sp
  191. import #{package_name}.data.#{view_name}Data
  192. import #{package_name}.viewmodels.#{view_name}ViewModel
  193. @Composable
  194. fun #{view_name}GeneratedView(
  195. data: #{view_name}Data,
  196. viewModel: #{view_name}ViewModel
  197. ) {
  198. // Generated Compose code from #{json_reference}.json
  199. // This will be updated when you run 'kjui build'
  200. // >>> GENERATED_CODE_START
  201. Column(
  202. modifier = Modifier
  203. .fillMaxSize()
  204. .padding(16.dp),
  205. horizontalAlignment = Alignment.CenterHorizontally
  206. ) {
  207. Text(
  208. text = data.title,
  209. fontSize = 24.sp,
  210. fontWeight = FontWeight.Bold
  211. )
  212. Spacer(modifier = Modifier.height(16.dp))
  213. Text(
  214. text = "Run 'kjui build' to generate Compose code",
  215. fontSize = 14.sp,
  216. color = Color.Gray
  217. )
  218. }
  219. // >>> GENERATED_CODE_END
  220. }
  221. KOTLIN
  222. 17 File.write(file_path, template)
  223. 17 puts "Created Generated View template: #{file_path}"
  224. end
  225. 1 def create_data_template(file_path, view_name, package_name)
  226. 17 return if File.exist?(file_path)
  227. 17 data_package = "#{package_name}.data"
  228. 17 template = <<~KOTLIN
  229. package #{data_package}
  230. data class #{view_name}Data(
  231. var title: String = "#{view_name}",
  232. // Action closures (called from generated views)
  233. var onGetStarted: (() -> Unit)? = null
  234. // Add more data properties as needed based on your JSON structure
  235. ) {
  236. // Update properties from map
  237. fun update(map: Map<String, Any>) {
  238. map["title"]?.let {
  239. if (it is String) title = it
  240. }
  241. }
  242. // Convert to map for dynamic mode
  243. fun toMap(): Map<String, Any> {
  244. return mutableMapOf(
  245. "title" to title
  246. )
  247. }
  248. }
  249. KOTLIN
  250. 17 File.write(file_path, template)
  251. 17 puts "Created Data template: #{file_path}"
  252. end
  253. 1 def create_viewmodel_template(file_path, view_name, json_name, subdirectory, package_name)
  254. 17 return if File.exist?(file_path)
  255. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  256. 17 viewmodel_package = "#{package_name}.viewmodels"
  257. 17 template = <<~KOTLIN
  258. // Generated by kjui_tools - DO NOT EDIT between GENERATED_CODE markers
  259. package #{viewmodel_package}
  260. import android.app.Application
  261. import androidx.lifecycle.AndroidViewModel
  262. import kotlinx.coroutines.flow.MutableStateFlow
  263. import kotlinx.coroutines.flow.StateFlow
  264. import kotlinx.coroutines.flow.asStateFlow
  265. import kotlinx.coroutines.flow.update
  266. import #{package_name}.data.#{view_name}Data
  267. class #{view_name}ViewModel(application: Application) : AndroidViewModel(application) {
  268. // JSON file reference for hot reload
  269. val jsonFileName = "#{json_reference}"
  270. // Data model
  271. private val _data = MutableStateFlow(#{view_name}Data())
  272. val data: StateFlow<#{view_name}Data> = _data.asStateFlow()
  273. // >>> GENERATED_CODE_START
  274. // Auto-generated updateData function - updated by 'kjui build'
  275. fun updateData(updates: Map<String, Any>) {
  276. _data.update { current ->
  277. var updated = current
  278. updates.forEach { (key, value) ->
  279. updated = when (key) {
  280. "title" -> updated.copy(title = value as? String ?: updated.title)
  281. else -> updated
  282. }
  283. }
  284. updated
  285. }
  286. }
  287. // >>> GENERATED_CODE_END
  288. // Add your custom action handlers below
  289. fun onGetStarted() {
  290. // Handle button tap
  291. }
  292. }
  293. KOTLIN
  294. 17 File.write(file_path, template)
  295. 17 puts "Created ViewModel template: #{file_path}"
  296. end
  297. 1 def update_main_activity(view_name, package_name)
  298. source_dir = @config['source_directory'] || 'src/main'
  299. # Find MainActivity file
  300. activity_files = Dir.glob(File.join(source_dir, '**/MainActivity.kt'))
  301. if activity_files.empty?
  302. puts "Warning: Could not find MainActivity.kt file to update"
  303. return
  304. end
  305. activity_file = activity_files.first
  306. content = File.read(activity_file)
  307. # Add required imports for DynamicModeManager and view
  308. view_folder_name = to_snake_case(view_name)
  309. required_imports = [
  310. "import com.kotlinjsonui.core.DynamicModeManager",
  311. "import #{package_name}.views.#{view_folder_name}.#{view_name}View"
  312. ]
  313. required_imports.each do |import_line|
  314. unless content.include?(import_line)
  315. # Find the last import line and add after it
  316. if content =~ /(^import .+$)/m
  317. last_import_match = content.scan(/^import .+$/).last
  318. if last_import_match
  319. content.sub!(last_import_match, "#{last_import_match}\n#{import_line}")
  320. end
  321. end
  322. end
  323. end
  324. # Add DynamicModeManager.setDynamicModeEnabled after super.onCreate if not present
  325. unless content.include?("DynamicModeManager.setDynamicModeEnabled")
  326. if content =~ /(super\.onCreate\(savedInstanceState\))/
  327. content.sub!($1, "#{$1}\n\n // Enable Dynamic Mode for HotLoader (debug builds only)\n DynamicModeManager.setDynamicModeEnabled(this, true)")
  328. end
  329. end
  330. # Update setContent block with DynamicModeManager integration
  331. updated = false
  332. # Find and replace the entire setContent block
  333. if content =~ /setContent\s*\{[\s\S]*?\n\s{8}\}/m
  334. content.gsub!(/setContent\s*\{[\s\S]*?\n\s{8}\}/m) do
  335. <<~KOTLIN.chomp
  336. setContent {
  337. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  338. KotlinJsonUITheme {
  339. Surface(
  340. modifier = Modifier.fillMaxSize(),
  341. color = MaterialTheme.colorScheme.background
  342. ) {
  343. key(isDynamicModeEnabled) {
  344. #{view_name}View()
  345. }
  346. }
  347. }
  348. }
  349. KOTLIN
  350. end
  351. updated = true
  352. elsif content =~ /setContent\s*\{[^}]*\}/m
  353. content.gsub!(/setContent\s*\{[^}]*\}/m) do
  354. <<~KOTLIN.chomp
  355. setContent {
  356. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  357. key(isDynamicModeEnabled) {
  358. #{view_name}View()
  359. }
  360. }
  361. KOTLIN
  362. end
  363. updated = true
  364. end
  365. if updated
  366. File.write(activity_file, content)
  367. puts "Updated MainActivity to use #{view_name}View as root with DynamicModeManager"
  368. else
  369. puts "Warning: Could not update MainActivity automatically"
  370. puts "Please manually update your MainActivity to use #{view_name}View()"
  371. end
  372. end
  373. end
  374. end
  375. end
  376. end

lib/compose/helpers/import_manager.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class ImportManager
  6. 1 def self.get_imports_map(package_name = nil)
  7. # Use provided package name or default to sample app
  8. 15 pkg_name = package_name || 'com.example.kotlinjsonui.sample'
  9. {
  10. 15 lazy_column: "import androidx.compose.foundation.lazy.LazyColumn",
  11. lazy_row: "import androidx.compose.foundation.lazy.LazyRow",
  12. background: "import androidx.compose.foundation.background",
  13. border: "import androidx.compose.foundation.border",
  14. shape: ["import androidx.compose.foundation.shape.RoundedCornerShape",
  15. "import androidx.compose.ui.draw.clip"],
  16. text_align: "import androidx.compose.ui.text.style.TextAlign",
  17. text_overflow: "import androidx.compose.ui.text.style.TextOverflow",
  18. text_style: "import androidx.compose.ui.text.TextStyle",
  19. visual_transformation: "import androidx.compose.ui.text.input.PasswordVisualTransformation",
  20. shadow: "import androidx.compose.ui.draw.shadow",
  21. arrangement: "import androidx.compose.foundation.layout.Arrangement",
  22. keyboard_type: ["import androidx.compose.foundation.text.KeyboardOptions",
  23. "import androidx.compose.ui.text.input.KeyboardType"],
  24. ime_action: "import androidx.compose.ui.text.input.ImeAction",
  25. ime_padding: "import androidx.compose.foundation.layout.imePadding",
  26. button_colors: "import androidx.compose.material3.ButtonDefaults",
  27. button_padding: "import androidx.compose.foundation.layout.PaddingValues",
  28. padding_values: "import androidx.compose.foundation.layout.PaddingValues",
  29. text_decoration: "import androidx.compose.ui.text.style.TextDecoration",
  30. shadow_style: ["import androidx.compose.ui.text.TextStyle",
  31. "import androidx.compose.ui.graphics.Shadow",
  32. "import androidx.compose.ui.geometry.Offset"],
  33. switch_colors: "import androidx.compose.material3.SwitchDefaults",
  34. slider_colors: "import androidx.compose.material3.SliderDefaults",
  35. checkbox_colors: "import androidx.compose.material3.CheckboxDefaults",
  36. dropdown_menu: ["import androidx.compose.material3.DropdownMenu",
  37. "import androidx.compose.material3.DropdownMenuItem",
  38. "import androidx.compose.material.icons.Icons",
  39. "import androidx.compose.material.icons.filled.ArrowDropDown",
  40. "import androidx.compose.foundation.clickable"],
  41. outlined_text_field: "import androidx.compose.material3.OutlinedTextField",
  42. icons: ["import androidx.compose.material.icons.Icons",
  43. "import androidx.compose.material.icons.filled.*",
  44. "import androidx.compose.material.icons.outlined.*"],
  45. icon_button: "import androidx.compose.material3.IconButton",
  46. clickable: "import androidx.compose.foundation.clickable",
  47. radio_colors: "import androidx.compose.material3.RadioButtonDefaults",
  48. tab_row: ["import androidx.compose.material3.TabRow",
  49. "import androidx.compose.material3.Tab"],
  50. async_image: "import coil.compose.AsyncImage",
  51. content_scale: "import androidx.compose.ui.layout.ContentScale",
  52. lazy_grid: ["import androidx.compose.foundation.lazy.grid.LazyVerticalGrid",
  53. "import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid",
  54. "import androidx.compose.foundation.lazy.grid.GridCells"],
  55. grid_item_span: "import androidx.compose.foundation.lazy.grid.GridItemSpan",
  56. webview: ["import android.webkit.WebView",
  57. "import android.webkit.WebViewClient",
  58. "import android.webkit.WebChromeClient",
  59. "import androidx.compose.ui.viewinterop.AndroidView"],
  60. constraint_layout: ["import androidx.constraintlayout.compose.ConstraintLayout",
  61. "import androidx.constraintlayout.compose.Dimension"],
  62. remember_state: ["import androidx.compose.runtime.remember",
  63. "import androidx.compose.runtime.mutableStateOf",
  64. "import androidx.compose.runtime.getValue",
  65. "import androidx.compose.runtime.setValue"],
  66. remember: "import androidx.compose.runtime.remember",
  67. LaunchedEffect: "import androidx.compose.runtime.LaunchedEffect",
  68. launched_effect: "import androidx.compose.runtime.LaunchedEffect",
  69. disposable_effect: "import androidx.compose.runtime.DisposableEffect",
  70. bias_alignment: "import androidx.compose.ui.BiasAlignment",
  71. circle_shape: "import androidx.compose.foundation.shape.CircleShape",
  72. alpha: "import androidx.compose.ui.draw.alpha",
  73. image: "import androidx.compose.foundation.Image",
  74. painter_resource: "import androidx.compose.ui.res.painterResource",
  75. string_resource: "import androidx.compose.ui.res.stringResource",
  76. color_resource: "import androidx.compose.ui.res.colorResource",
  77. r_class: "import #{pkg_name}.R",
  78. gradient: "import androidx.compose.ui.graphics.Brush",
  79. blur: "import androidx.compose.ui.draw.blur",
  80. navigation: ["import androidx.navigation.NavController",
  81. "import androidx.navigation.compose.NavHost",
  82. "import androidx.navigation.compose.composable",
  83. "import androidx.navigation.compose.rememberNavController"],
  84. selectbox_component: "import com.kotlinjsonui.components.SelectBox",
  85. date_selectbox_component: "import com.kotlinjsonui.components.DateSelectBox",
  86. simple_date_selectbox_component: "import com.kotlinjsonui.components.SimpleDateSelectBox",
  87. visibility_wrapper: "import com.kotlinjsonui.components.VisibilityWrapper",
  88. custom_textfield: ["import com.kotlinjsonui.components.CustomTextField",
  89. "import com.kotlinjsonui.components.CustomTextFieldWithMargins"],
  90. annotated_string: ["import androidx.compose.ui.text.AnnotatedString",
  91. "import androidx.compose.ui.text.buildAnnotatedString",
  92. "import androidx.compose.ui.text.SpanStyle",
  93. "import androidx.compose.ui.text.withStyle"],
  94. clickable_text: "import androidx.compose.foundation.text.ClickableText",
  95. partial_attributes_text: ["import com.kotlinjsonui.components.PartialAttributesText",
  96. "import com.kotlinjsonui.components.PartialAttribute"],
  97. segment: "import com.kotlinjsonui.components.Segment",
  98. dynamic_mode_manager: "import com.kotlinjsonui.core.DynamicModeManager",
  99. configuration: "import com.kotlinjsonui.core.Configuration",
  100. safe_dynamic_view: "import com.kotlinjsonui.components.SafeDynamicView",
  101. circular_progress_indicator: "import androidx.compose.material3.CircularProgressIndicator",
  102. wrapContentSize: "import androidx.compose.foundation.layout.wrapContentSize",
  103. box: "import androidx.compose.foundation.layout.Box",
  104. DynamicView: "import com.kotlinjsonui.dynamic.DynamicView",
  105. JsonObject: "import com.google.gson.JsonObject",
  106. JsonParser: "import com.google.gson.JsonParser",
  107. dashed_border: ["import com.kotlinjsonui.dynamic.helpers.dashedBorder",
  108. "import com.kotlinjsonui.dynamic.helpers.dottedBorder"]
  109. }
  110. end
  111. 1 def self.update_imports(content, required_imports)
  112. 6 imports_map = get_imports_map
  113. 6 required_imports.each do |import_key|
  114. 6 import_lines = imports_map[import_key]
  115. 6 next unless import_lines
  116. 5 if import_lines.is_a?(Array)
  117. 1 import_lines.each do |import_line|
  118. 2 unless content.include?(import_line)
  119. # Add import after the last import statement
  120. 2 if content =~ /^(import .+\n)+/m
  121. 2 last_import_end = $~.end(0)
  122. 2 content.insert(last_import_end, "#{import_line}\n")
  123. end
  124. end
  125. end
  126. else
  127. 4 unless content.include?(import_lines)
  128. # Add import after the last import statement
  129. 3 if content =~ /^(import .+\n)+/m
  130. 3 last_import_end = $~.end(0)
  131. 3 content.insert(last_import_end, "#{import_lines}\n")
  132. end
  133. end
  134. end
  135. end
  136. 6 content
  137. end
  138. end
  139. end
  140. end
  141. end

lib/compose/helpers/modifier_builder.rb

71.76% lines covered

393 relevant lines. 282 lines covered and 111 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'resource_resolver'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Helpers
  6. # Helper class to build Compose modifiers from JSON attributes
  7. 1 class ModifierBuilder
  8. 1 def self.build_padding(json_data)
  9. 412 modifiers = []
  10. # Handle padding attribute (can be array [top, right, bottom, left] or single value)
  11. 412 if json_data['padding']
  12. 9 if json_data['padding'].is_a?(Array)
  13. 1 pad_values = json_data['padding']
  14. 1 if pad_values.length == 4
  15. 1 modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  16. elsif pad_values.length == 1
  17. modifiers << ".padding(#{pad_values[0]}.dp)"
  18. end
  19. else
  20. 8 modifiers << ".padding(#{json_data['padding']}.dp)"
  21. end
  22. end
  23. # Handle paddings attribute (same as padding)
  24. 412 if json_data['paddings']
  25. 1 if json_data['paddings'].is_a?(Array)
  26. pad_values = json_data['paddings']
  27. if pad_values.length == 4
  28. modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  29. elsif pad_values.length == 1
  30. modifiers << ".padding(#{pad_values[0]}.dp)"
  31. end
  32. else
  33. 1 modifiers << ".padding(#{json_data['paddings']}.dp)"
  34. end
  35. end
  36. # Individual padding attributes
  37. 412 modifiers << ".padding(top = #{json_data['paddingTop']}.dp)" if json_data['paddingTop']
  38. 412 modifiers << ".padding(bottom = #{json_data['paddingBottom']}.dp)" if json_data['paddingBottom']
  39. 412 modifiers << ".padding(start = #{json_data['paddingLeft']}.dp)" if json_data['paddingLeft']
  40. 412 modifiers << ".padding(end = #{json_data['paddingRight']}.dp)" if json_data['paddingRight']
  41. 412 modifiers
  42. end
  43. 1 def self.build_margins(json_data)
  44. 488 modifiers = []
  45. # Handle margins attribute (can be array [top, right, bottom, left] or single value)
  46. 488 if json_data['margins']
  47. 16 if json_data['margins'].is_a?(Array)
  48. 15 margin_values = json_data['margins']
  49. 15 if margin_values.length == 4
  50. 5 modifiers << ".padding(top = #{margin_values[0]}.dp, end = #{margin_values[1]}.dp, bottom = #{margin_values[2]}.dp, start = #{margin_values[3]}.dp)"
  51. 10 elsif margin_values.length == 1
  52. 5 modifiers << ".padding(#{margin_values[0]}.dp)"
  53. end
  54. else
  55. 1 modifiers << ".padding(#{json_data['margins']}.dp)"
  56. end
  57. end
  58. # Individual margin attributes (with binding support)
  59. 488 modifiers << ".padding(top = #{margin_value(json_data['topMargin'])})" if json_data['topMargin']
  60. 488 modifiers << ".padding(bottom = #{margin_value(json_data['bottomMargin'])})" if json_data['bottomMargin']
  61. 488 modifiers << ".padding(start = #{margin_value(json_data['leftMargin'])})" if json_data['leftMargin']
  62. 488 modifiers << ".padding(end = #{margin_value(json_data['rightMargin'])})" if json_data['rightMargin']
  63. # RTL aware margins
  64. 488 modifiers << ".padding(start = #{margin_value(json_data['startMargin'])})" if json_data['startMargin']
  65. 488 modifiers << ".padding(end = #{margin_value(json_data['endMargin'])})" if json_data['endMargin']
  66. 488 modifiers
  67. end
  68. # Convert margin value to Kotlin/Compose format with binding support
  69. 1 def self.margin_value(value)
  70. 7 if is_binding?(value)
  71. # Data binding: @{propertyName} -> data.propertyName.dp
  72. property = extract_binding_property(value)
  73. "data.#{property}.dp"
  74. else
  75. 7 "#{value}.dp"
  76. end
  77. end
  78. 1 def self.build_weight(json_data, parent_orientation = nil)
  79. 7 modifiers = []
  80. # Weight only works in Row/Column contexts
  81. # Weight must be greater than 0 in Compose
  82. 7 if json_data['weight'] && parent_orientation && json_data['weight'].to_f > 0
  83. 5 modifiers << ".weight(#{json_data['weight']}f)"
  84. end
  85. 7 modifiers
  86. end
  87. 1 def self.build_size(json_data)
  88. 362 modifiers = []
  89. # Handle 'frame' attribute - object with width/height
  90. # frame: { width: 100, height: 50 }
  91. 362 if json_data['frame'].is_a?(Hash)
  92. frame = json_data['frame']
  93. if frame['width']
  94. if frame['width'] == 'matchParent'
  95. modifiers << ".fillMaxWidth()"
  96. elsif frame['width'] == 'wrapContent'
  97. modifiers << ".wrapContentWidth()"
  98. else
  99. modifiers << ".width(#{process_dimension(frame['width'])})"
  100. end
  101. end
  102. if frame['height']
  103. if frame['height'] == 'matchParent'
  104. modifiers << ".fillMaxHeight()"
  105. elsif frame['height'] == 'wrapContent'
  106. modifiers << ".wrapContentHeight()"
  107. else
  108. modifiers << ".height(#{process_dimension(frame['height'])})"
  109. end
  110. end
  111. # If frame is specified, skip individual width/height processing
  112. return modifiers
  113. end
  114. # Width - skip if weight is present and width is 0
  115. 362 if json_data['width'] == 'matchParent'
  116. 3 modifiers << ".fillMaxWidth()"
  117. 359 elsif json_data['width'] == 'wrapContent'
  118. 1 modifiers << ".wrapContentWidth()"
  119. 358 elsif json_data['width'] && !(json_data['weight'] && json_data['width'] == 0)
  120. 11 modifiers << ".width(#{process_dimension(json_data['width'])})"
  121. end
  122. # Height - skip if heightWeight is present and height is 0
  123. 362 if json_data['height'] == 'matchParent'
  124. 1 modifiers << ".fillMaxHeight()"
  125. 361 elsif json_data['height'] == 'wrapContent'
  126. modifiers << ".wrapContentHeight()"
  127. 361 elsif json_data['height'] && !(json_data['heightWeight'] && json_data['height'] == 0)
  128. 9 modifiers << ".height(#{process_dimension(json_data['height'])})"
  129. end
  130. # Min/Max constraints
  131. 362 if json_data['minWidth']
  132. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp)"
  133. end
  134. 362 if json_data['maxWidth']
  135. 1 modifiers << ".widthIn(max = #{json_data['maxWidth']}.dp)"
  136. end
  137. 362 if json_data['minHeight']
  138. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp)"
  139. end
  140. 362 if json_data['maxHeight']
  141. modifiers << ".heightIn(max = #{json_data['maxHeight']}.dp)"
  142. end
  143. # Combined min/max if both specified
  144. 362 if json_data['minWidth'] && json_data['maxWidth']
  145. 3 modifiers = modifiers.reject { |m| m.include?('.widthIn') }
  146. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp, max = #{json_data['maxWidth']}.dp)"
  147. end
  148. 362 if json_data['minHeight'] && json_data['maxHeight']
  149. modifiers = modifiers.reject { |m| m.include?('.heightIn') }
  150. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp, max = #{json_data['maxHeight']}.dp)"
  151. end
  152. # Aspect ratio
  153. 362 if json_data['aspectWidth'] && json_data['aspectHeight']
  154. 1 ratio = json_data['aspectWidth'].to_f / json_data['aspectHeight'].to_f
  155. 1 modifiers << ".aspectRatio(#{ratio}f)"
  156. end
  157. 362 modifiers
  158. end
  159. 1 def self.build_shadow(json_data, required_imports = nil)
  160. 47 modifiers = []
  161. 47 if json_data['shadow']
  162. 3 required_imports&.add(:shadow)
  163. 3 if json_data['shadow'].is_a?(String)
  164. # Simple shadow with color
  165. 2 modifiers << ".shadow(4.dp, shape = RectangleShape)"
  166. 1 elsif json_data['shadow'].is_a?(Hash)
  167. # Complex shadow configuration
  168. 1 shadow = json_data['shadow']
  169. 1 elevation = shadow['radius'] || 4
  170. 1 shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  171. 1 modifiers << ".shadow(#{elevation}.dp, shape = #{shape})"
  172. end
  173. end
  174. 47 modifiers
  175. end
  176. 1 def self.build_background(json_data, required_imports = nil)
  177. 162 modifiers = []
  178. 162 if json_data['background']
  179. 3 required_imports&.add(:background)
  180. # Use ResourceResolver to process background color
  181. 3 background_color = ResourceResolver.process_color(json_data['background'], required_imports)
  182. 3 if json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  183. 1 required_imports&.add(:border)
  184. 1 required_imports&.add(:shape)
  185. 1 if json_data['cornerRadius']
  186. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  187. end
  188. 1 if json_data['borderColor'] && json_data['borderWidth']
  189. modifiers << build_border_modifier(json_data, required_imports)
  190. end
  191. 1 modifiers << ".background(#{background_color})"
  192. else
  193. 2 modifiers << ".background(#{background_color})"
  194. end
  195. 159 elsif json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  196. 1 required_imports&.add(:border)
  197. 1 required_imports&.add(:shape)
  198. 1 if json_data['cornerRadius']
  199. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  200. end
  201. 1 if json_data['borderColor'] && json_data['borderWidth']
  202. 1 modifiers << build_border_modifier(json_data, required_imports)
  203. end
  204. end
  205. 162 modifiers
  206. end
  207. 1 def self.build_visibility(json_data, required_imports = nil)
  208. 117 modifiers = []
  209. 117 visibility_info = {}
  210. # Handle visibility attribute (static or data-bound)
  211. 117 if json_data['visibility']
  212. 5 if json_data['visibility'].is_a?(String) && json_data['visibility'].start_with?('@{')
  213. # Data binding for visibility
  214. 2 variable = json_data['visibility'].gsub('@{', '').gsub('}', '')
  215. 2 visibility_info[:visibility_binding] = "data.#{variable}"
  216. 2 required_imports&.add(:visibility_wrapper)
  217. else
  218. # Static visibility
  219. 3 visibility_info[:visibility] = json_data['visibility']
  220. 3 required_imports&.add(:visibility_wrapper)
  221. end
  222. end
  223. # Handle hidden attribute (boolean or data binding)
  224. 117 if json_data['hidden']
  225. 4 if json_data['hidden'].is_a?(String) && json_data['hidden'].start_with?('@{')
  226. # Data binding for hidden
  227. 2 variable = json_data['hidden'].gsub('@{', '').gsub('}', '')
  228. 2 visibility_info[:hidden_binding] = "data.#{variable}"
  229. 2 required_imports&.add(:visibility_wrapper)
  230. 2 elsif json_data['hidden'] == true
  231. 2 visibility_info[:hidden] = true
  232. 2 required_imports&.add(:visibility_wrapper)
  233. end
  234. end
  235. # Handle alpha/opacity attribute separately (not part of visibility wrapper)
  236. # Support both 'alpha' and 'opacity' for compatibility
  237. 117 alpha_value = json_data['alpha'] || json_data['opacity']
  238. 117 if alpha_value
  239. 2 required_imports&.add(:alpha)
  240. 2 modifiers << ".alpha(#{alpha_value}f)"
  241. end
  242. # Return both visibility info and modifiers
  243. 117 { modifiers: modifiers, visibility_info: visibility_info }
  244. end
  245. 1 def self.build_alignment(json_data, required_imports = nil, parent_type = nil)
  246. 141 modifiers = []
  247. # For Row, only vertical alignment is allowed
  248. 141 if parent_type == 'Row'
  249. 4 if json_data['alignTop']
  250. 1 modifiers << ".align(Alignment.Top)"
  251. 3 elsif json_data['alignBottom']
  252. modifiers << ".align(Alignment.Bottom)"
  253. 3 elsif json_data['centerVertical']
  254. 1 modifiers << ".align(Alignment.CenterVertically)"
  255. end
  256. # For Column, only horizontal alignment is allowed
  257. 137 elsif parent_type == 'Column'
  258. 4 if json_data['alignLeft']
  259. 1 modifiers << ".align(Alignment.Start)"
  260. 3 elsif json_data['alignRight']
  261. modifiers << ".align(Alignment.End)"
  262. 3 elsif json_data['centerHorizontal']
  263. 1 modifiers << ".align(Alignment.CenterHorizontally)"
  264. end
  265. # For Box and other containers, full alignment options
  266. 133 elsif parent_type == 'Box'
  267. # Check if any alignment is specified
  268. 4 has_alignment = json_data['alignTop'] || json_data['alignBottom'] ||
  269. json_data['alignLeft'] || json_data['alignRight'] ||
  270. json_data['centerHorizontal'] || json_data['centerVertical'] ||
  271. json_data['centerInParent']
  272. # First check for both-direction constraints (centering behavior)
  273. 4 has_horizontal_both = json_data['alignLeft'] && json_data['alignRight']
  274. 4 has_vertical_both = json_data['alignTop'] && json_data['alignBottom']
  275. # Handle combined alignments
  276. 4 if has_horizontal_both && has_vertical_both
  277. # Both horizontal and vertical constraints - center completely
  278. modifiers << ".align(Alignment.Center)"
  279. 4 elsif has_horizontal_both && json_data['alignTop']
  280. # Center horizontally, align top
  281. required_imports&.add(:bias_alignment)
  282. modifiers << ".align(BiasAlignment(0f, -1f))"
  283. 4 elsif has_horizontal_both && json_data['alignBottom']
  284. # Center horizontally, align bottom
  285. required_imports&.add(:bias_alignment)
  286. modifiers << ".align(BiasAlignment(0f, 1f))"
  287. 4 elsif has_horizontal_both
  288. # Just center horizontally
  289. required_imports&.add(:bias_alignment)
  290. modifiers << ".align(BiasAlignment(0f, 0f))"
  291. 4 elsif has_vertical_both && json_data['alignLeft']
  292. # Center vertically, align left
  293. modifiers << ".align(Alignment.CenterStart)"
  294. 4 elsif has_vertical_both && json_data['alignRight']
  295. # Center vertically, align right
  296. modifiers << ".align(Alignment.CenterEnd)"
  297. 4 elsif has_vertical_both
  298. # Just center vertically
  299. required_imports&.add(:bias_alignment)
  300. modifiers << ".align(BiasAlignment(0f, 0f))"
  301. 4 elsif json_data['alignTop'] && json_data['alignLeft']
  302. 1 modifiers << ".align(Alignment.TopStart)"
  303. 3 elsif json_data['alignTop'] && json_data['alignRight']
  304. modifiers << ".align(Alignment.TopEnd)"
  305. 3 elsif json_data['alignBottom'] && json_data['alignLeft']
  306. modifiers << ".align(Alignment.BottomStart)"
  307. 3 elsif json_data['alignBottom'] && json_data['alignRight']
  308. 1 modifiers << ".align(Alignment.BottomEnd)"
  309. 2 elsif json_data['alignTop'] && json_data['centerHorizontal']
  310. # TopCenter doesn't exist in BoxScope, use BiasAlignment
  311. 1 required_imports&.add(:bias_alignment)
  312. 1 modifiers << ".align(BiasAlignment(0f, -1f))"
  313. 1 elsif json_data['alignBottom'] && json_data['centerHorizontal']
  314. # BottomCenter doesn't exist in BoxScope, use BiasAlignment
  315. required_imports&.add(:bias_alignment)
  316. modifiers << ".align(BiasAlignment(0f, 1f))"
  317. 1 elsif json_data['alignLeft'] && json_data['centerVertical']
  318. modifiers << ".align(Alignment.CenterStart)"
  319. 1 elsif json_data['alignRight'] && json_data['centerVertical']
  320. modifiers << ".align(Alignment.CenterEnd)"
  321. 1 elsif json_data['centerInParent']
  322. 1 modifiers << ".align(Alignment.Center)"
  323. # Handle single alignments for Box
  324. elsif json_data['alignTop']
  325. # Just top alignment - align to top-left
  326. required_imports&.add(:bias_alignment)
  327. modifiers << ".align(BiasAlignment(-1f, -1f))"
  328. elsif json_data['alignBottom']
  329. # Just bottom alignment - align to bottom-left
  330. required_imports&.add(:bias_alignment)
  331. modifiers << ".align(BiasAlignment(-1f, 1f))"
  332. elsif json_data['alignLeft']
  333. # Just left alignment - align to top-left
  334. required_imports&.add(:bias_alignment)
  335. modifiers << ".align(BiasAlignment(-1f, -1f))"
  336. elsif json_data['alignRight']
  337. # Just right alignment - align to top-right
  338. required_imports&.add(:bias_alignment)
  339. modifiers << ".align(BiasAlignment(1f, -1f))"
  340. elsif json_data['centerHorizontal']
  341. # Center horizontally only - align to top-center
  342. required_imports&.add(:bias_alignment)
  343. modifiers << ".align(BiasAlignment(0f, -1f))"
  344. elsif json_data['centerVertical']
  345. # Center vertically only - align to center-left
  346. required_imports&.add(:bias_alignment)
  347. modifiers << ".align(BiasAlignment(-1f, 0f))"
  348. elsif !has_alignment
  349. # No alignment specified - default to TopStart (top-left)
  350. modifiers << ".align(Alignment.TopStart)"
  351. end
  352. end
  353. 141 modifiers
  354. end
  355. 1 def self.build_relative_positioning(json_data)
  356. # These attributes require ConstraintLayout
  357. # They generate constraint references instead of modifiers
  358. 8 constraints = []
  359. # Extract margins for use in constraints (with binding support)
  360. 8 top_margin = constraint_margin_value(json_data['topMargin'])
  361. 8 bottom_margin = constraint_margin_value(json_data['bottomMargin'])
  362. 8 start_margin = constraint_margin_value(json_data['leftMargin'])
  363. 8 end_margin = constraint_margin_value(json_data['rightMargin'])
  364. 8 if json_data['margins'] && json_data['margins'].is_a?(Array) && json_data['margins'].length == 4
  365. top_margin = json_data['margins'][0].to_s + ".dp" unless json_data['topMargin']
  366. end_margin = json_data['margins'][1].to_s + ".dp" unless json_data['rightMargin']
  367. bottom_margin = json_data['margins'][2].to_s + ".dp" unless json_data['bottomMargin']
  368. start_margin = json_data['margins'][3].to_s + ".dp" unless json_data['leftMargin']
  369. end
  370. # Relative to other views
  371. 8 if json_data['alignTopOfView']
  372. 2 margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
  373. 2 constraints << "bottom.linkTo(#{json_data['alignTopOfView']}.top#{margin})"
  374. end
  375. 8 if json_data['alignBottomOfView']
  376. margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
  377. constraints << "top.linkTo(#{json_data['alignBottomOfView']}.bottom#{margin})"
  378. end
  379. 8 if json_data['alignLeftOfView']
  380. margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
  381. constraints << "end.linkTo(#{json_data['alignLeftOfView']}.start#{margin})"
  382. end
  383. 8 if json_data['alignRightOfView']
  384. margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
  385. constraints << "start.linkTo(#{json_data['alignRightOfView']}.end#{margin})"
  386. end
  387. # Align edges with other views
  388. # For align operations, use negative margins to move in the expected direction
  389. 8 if json_data['alignTopView']
  390. # alignTop with topMargin means move DOWN from the aligned position
  391. # linkTo margin pushes away, so use negative to pull closer (move down)
  392. margin = has_constraint_margin?(top_margin) ? ", margin = (-#{top_margin})" : ""
  393. constraints << "top.linkTo(#{json_data['alignTopView']}.top#{margin})"
  394. end
  395. 8 if json_data['alignBottomView']
  396. # alignBottom with bottomMargin means move UP from the aligned position
  397. # linkTo margin pushes away, so use negative to pull closer (move up)
  398. margin = has_constraint_margin?(bottom_margin) ? ", margin = (-#{bottom_margin})" : ""
  399. constraints << "bottom.linkTo(#{json_data['alignBottomView']}.bottom#{margin})"
  400. end
  401. 8 if json_data['alignLeftView']
  402. # alignLeft with leftMargin means move RIGHT from the aligned position
  403. # linkTo margin pushes away, so use negative to pull closer (move right)
  404. margin = has_constraint_margin?(start_margin) ? ", margin = (-#{start_margin})" : ""
  405. constraints << "start.linkTo(#{json_data['alignLeftView']}.start#{margin})"
  406. end
  407. 8 if json_data['alignRightView']
  408. # alignRight with rightMargin means move LEFT from the aligned position
  409. # linkTo margin pushes away, so use negative to pull closer (move left)
  410. margin = has_constraint_margin?(end_margin) ? ", margin = (-#{end_margin})" : ""
  411. constraints << "end.linkTo(#{json_data['alignRightView']}.end#{margin})"
  412. end
  413. # Center with other views
  414. 8 if json_data['alignCenterVerticalView']
  415. constraints << "top.linkTo(#{json_data['alignCenterVerticalView']}.top)"
  416. constraints << "bottom.linkTo(#{json_data['alignCenterVerticalView']}.bottom)"
  417. end
  418. 8 if json_data['alignCenterHorizontalView']
  419. constraints << "start.linkTo(#{json_data['alignCenterHorizontalView']}.start)"
  420. constraints << "end.linkTo(#{json_data['alignCenterHorizontalView']}.end)"
  421. end
  422. # Parent constraints
  423. # For parent alignment, margins should work normally as offsets
  424. 8 if json_data['alignTop']
  425. 4 margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
  426. 4 constraints << "top.linkTo(parent.top#{margin})"
  427. end
  428. 8 if json_data['alignBottom']
  429. margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
  430. constraints << "bottom.linkTo(parent.bottom#{margin})"
  431. end
  432. 8 if json_data['alignLeft']
  433. 2 margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
  434. 2 constraints << "start.linkTo(parent.start#{margin})"
  435. end
  436. 8 if json_data['alignRight']
  437. margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
  438. constraints << "end.linkTo(parent.end#{margin})"
  439. end
  440. 8 if json_data['centerHorizontal']
  441. constraints << "start.linkTo(parent.start)"
  442. constraints << "end.linkTo(parent.end)"
  443. end
  444. 8 if json_data['centerVertical']
  445. constraints << "top.linkTo(parent.top)"
  446. constraints << "bottom.linkTo(parent.bottom)"
  447. end
  448. 8 if json_data['centerInParent']
  449. 2 constraints << "top.linkTo(parent.top)"
  450. 2 constraints << "bottom.linkTo(parent.bottom)"
  451. 2 constraints << "start.linkTo(parent.start)"
  452. 2 constraints << "end.linkTo(parent.end)"
  453. end
  454. 8 constraints
  455. end
  456. 1 def self.format(modifiers, depth)
  457. 187 return "" if modifiers.empty?
  458. # Check if first modifier is already "Modifier"
  459. 102 if modifiers[0] == "Modifier"
  460. 3 code = "\n" + indent("modifier = Modifier", depth + 1)
  461. # Skip the first "Modifier" and process the rest
  462. 3 modifiers[1..-1].each do |mod|
  463. 9 code += "\n" + indent(" #{mod}", depth + 1)
  464. end
  465. else
  466. 99 code = "\n" + indent("modifier = Modifier", depth + 1)
  467. 99 if modifiers.length == 1 && modifiers[0].start_with?('.')
  468. 72 code += modifiers[0]
  469. else
  470. 27 modifiers.each do |mod|
  471. 57 code += "\n" + indent(" #{mod}", depth + 1)
  472. end
  473. end
  474. end
  475. 102 code
  476. end
  477. # Build lifecycle event effects (onAppear/onDisappear)
  478. # Returns a hash with :before (code before content) and :after (code after content)
  479. 1 def self.build_lifecycle_effects(json_data, depth, required_imports = nil)
  480. 16 result = { before: "", after: "" }
  481. 16 if json_data['onAppear']
  482. 9 required_imports&.add(:launched_effect)
  483. 9 handler = json_data['onAppear']
  484. # Strip @{} binding syntax if present
  485. 9 property = is_binding?(handler) ? extract_binding_property(handler) : handler
  486. # Also strip : prefix if present
  487. 9 property = property.gsub(':', '') if property.include?(':')
  488. 9 result[:before] += indent("// onAppear lifecycle event", depth)
  489. 9 result[:before] += "\n" + indent("LaunchedEffect(Unit) {", depth)
  490. 9 result[:before] += "\n" + indent("data.#{property}?.invoke()", depth + 1)
  491. 9 result[:before] += "\n" + indent("}", depth)
  492. 9 result[:before] += "\n"
  493. end
  494. 16 if json_data['onDisappear']
  495. 7 required_imports&.add(:disposable_effect)
  496. 7 handler = json_data['onDisappear']
  497. # Strip @{} binding syntax if present
  498. 7 property = is_binding?(handler) ? extract_binding_property(handler) : handler
  499. # Also strip : prefix if present
  500. 7 property = property.gsub(':', '') if property.include?(':')
  501. 7 result[:before] += indent("// onDisappear lifecycle event", depth)
  502. 7 result[:before] += "\n" + indent("DisposableEffect(Unit) {", depth)
  503. 7 result[:before] += "\n" + indent("onDispose {", depth + 1)
  504. 7 result[:before] += "\n" + indent("data.#{property}?.invoke()", depth + 2)
  505. 7 result[:before] += "\n" + indent("}", depth + 1)
  506. 7 result[:before] += "\n" + indent("}", depth)
  507. 7 result[:before] += "\n"
  508. end
  509. 16 result
  510. end
  511. # Check if component has lifecycle events
  512. 1 def self.has_lifecycle_events?(json_data)
  513. 9 json_data['onAppear'] || json_data['onDisappear']
  514. end
  515. # Convert event handler to method call
  516. # onClick -> binding format only: @{functionName} -> data.functionName?.invoke()
  517. 1 def self.get_event_handler_call(handler, is_camel_case: false)
  518. # Extract function name from binding format @{functionName}
  519. 4 if handler.match?(/^@\{(.+)\}$/)
  520. method_name = handler.match(/^@\{(.+)\}$/)[1]
  521. "data.#{method_name}?.invoke()"
  522. else
  523. # Direct function name (non-binding)
  524. 4 "data.#{handler}?.invoke()"
  525. end
  526. end
  527. # Check if handler is binding format (@{functionName})
  528. 1 def self.is_binding?(value)
  529. 41 value.is_a?(String) && value.match?(/^@\{.+\}$/)
  530. end
  531. # Extract property name from binding expression
  532. # "@{propertyName}" -> "propertyName"
  533. 1 def self.extract_binding_property(value)
  534. 9 return nil unless value.is_a?(String)
  535. 9 if value.match(/^@\{(.+)\}$/)
  536. 9 $1
  537. else
  538. value
  539. end
  540. end
  541. # Convert margin value to Kotlin/Compose format for constraint linkTo() with binding support
  542. # Returns nil for no margin, or the formatted value (e.g., "8.dp" or "data.margin.dp")
  543. 1 def self.constraint_margin_value(value)
  544. 32 return nil if value.nil?
  545. 1 if is_binding?(value)
  546. # Data binding: @{propertyName} -> data.propertyName.dp
  547. property = extract_binding_property(value)
  548. "data.#{property}.dp"
  549. 1 elsif value.is_a?(Numeric) && value > 0
  550. 1 "#{value}.dp"
  551. elsif value.is_a?(String)
  552. # Try to parse as number
  553. num = value.to_i
  554. num > 0 ? "#{num}.dp" : nil
  555. else
  556. nil
  557. end
  558. end
  559. # Check if constraint margin value is present
  560. 1 def self.has_constraint_margin?(margin_value)
  561. 8 return false if margin_value.nil?
  562. 1 return true if margin_value.is_a?(String) && margin_value.length > 0
  563. false
  564. end
  565. 1 private
  566. # Build border modifier with support for solid/dashed/dotted styles
  567. 1 def self.build_border_modifier(json_data, required_imports = nil)
  568. 1 border_color = ResourceResolver.process_color(json_data['borderColor'], required_imports)
  569. 1 border_width = json_data['borderWidth']
  570. 1 border_style = json_data['borderStyle'] || 'solid'
  571. 1 border_shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  572. 1 case border_style
  573. when 'dashed'
  574. required_imports&.add(:dashed_border)
  575. ".dashedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
  576. when 'dotted'
  577. required_imports&.add(:dashed_border)
  578. ".dottedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
  579. else # 'solid' or default
  580. 1 ".border(#{border_width}.dp, #{border_color}, #{border_shape})"
  581. end
  582. end
  583. # Process dimension value - handles data bindings and numeric values
  584. 1 def self.process_dimension(value)
  585. 26 return "#{value}.dp" if value.is_a?(Numeric)
  586. 5 if value.is_a?(String)
  587. # Check for data binding syntax @{variableName}
  588. 4 if value.match(/@\{([^}]+)\}/)
  589. 2 variable = $1
  590. # Data binding returns Int/Float from ViewModel, append .dp
  591. 2 return "data.#{variable}.dp"
  592. end
  593. # Regular string value (might be percentage or other)
  594. 2 return "#{value}.dp"
  595. end
  596. 1 "0.dp"
  597. end
  598. 1 def self.indent(text, level)
  599. 246 return text if level == 0
  600. 243 spaces = ' ' * level
  601. 243 text.split("\n").map { |line|
  602. 243 line.empty? ? line : spaces + line
  603. }.join("\n")
  604. end
  605. end
  606. end
  607. end
  608. end

lib/compose/helpers/resource_resolver.rb

84.68% lines covered

111 relevant lines. 94 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'rexml/document'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Helpers
  9. 1 class ResourceResolver
  10. 1 class << self
  11. # Don't cache - just load each time to avoid issues
  12. 1 def cached_config
  13. 481 Core::ConfigManager.load_config
  14. end
  15. 1 def cached_source_path
  16. 242 Core::ProjectFinder.get_full_source_path || Dir.pwd
  17. end
  18. # Process text with data binding and resource resolution
  19. 1 def process_text(text, required_imports = nil)
  20. 115 return quote(text) unless text.is_a?(String)
  21. # Handle data binding expressions
  22. 115 if text.match(/@\{([^}]+)\}/)
  23. 3 variable = $1
  24. 3 if variable.include?(' ?? ')
  25. 1 parts = variable.split(' ?? ')
  26. 1 var_name = parts[0].strip
  27. 1 return "\"\${data.#{var_name}}\""
  28. else
  29. 2 return "\"\${data.#{variable}}\""
  30. end
  31. end
  32. # Skip resource resolution if we're in the extraction phase
  33. # (Resources directory doesn't exist yet)
  34. 112 source_directory = cached_config['source_directory'] || 'src/main'
  35. 112 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  36. 112 resources_dir = File.join(layouts_dir, 'Resources')
  37. # If Resources directory doesn't exist, we're in extraction phase
  38. # Just return quoted text
  39. 112 return quote(text) unless File.exist?(resources_dir)
  40. # Try to resolve as a string resource
  41. 1 resolved = resolve_string(text, cached_config, cached_source_path)
  42. 1 if resolved.include?('stringResource')
  43. 1 required_imports&.add(:string_resource)
  44. 1 required_imports&.add(:r_class)
  45. end
  46. 1 resolved
  47. end
  48. # Process color with resource resolution
  49. 1 def process_color(color, required_imports = nil)
  50. 126 return nil unless color.is_a?(String)
  51. # Handle data binding expressions
  52. 125 if color.start_with?('@{') || color.start_with?('${}')
  53. 1 return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  54. end
  55. # Skip resource resolution if we're in the extraction phase
  56. # (Resources directory doesn't exist yet)
  57. 124 source_directory = cached_config['source_directory'] || 'src/main'
  58. 124 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  59. 124 resources_dir = File.join(layouts_dir, 'Resources')
  60. # If Resources directory doesn't exist, we're in extraction phase
  61. # Just return standard color parsing
  62. 124 unless File.exist?(resources_dir)
  63. 122 return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  64. end
  65. 2 resolved = resolve_color(color, cached_config, cached_source_path)
  66. 2 if resolved&.include?('colorResource')
  67. 2 required_imports&.add(:color_resource)
  68. 2 required_imports&.add(:r_class)
  69. end
  70. 2 resolved
  71. end
  72. 1 private
  73. # Check if a string resource exists in strings.xml
  74. 1 def resolve_string(text, config, source_path)
  75. 1 return quote(text) unless text.is_a?(String)
  76. # Skip if it's a data binding expression
  77. 1 return quote(text) if text.start_with?('@{') || text.start_with?('${')
  78. # Try to find the string in strings.xml
  79. 1 string_key = find_string_key(text, config, source_path)
  80. 1 if string_key
  81. # Return stringResource reference
  82. 1 "stringResource(R.string.#{string_key})"
  83. else
  84. # Return quoted string
  85. quote(text)
  86. end
  87. end
  88. # Check if a color resource exists
  89. 1 def resolve_color(color, config, source_path)
  90. 2 return nil unless color.is_a?(String)
  91. # Skip if it's a data binding expression
  92. 2 return "Color(android.graphics.Color.parseColor(#{quote(color)}))" if color.start_with?('@{') || color.start_with?('${')
  93. # Try to find the color in colors.json
  94. 2 color_key = find_color_key(color, config, source_path)
  95. 2 if color_key
  96. # Return colorResource reference
  97. 2 "colorResource(R.color.#{color_key})"
  98. else
  99. # Return Color.parseColor
  100. "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  101. end
  102. end
  103. 1 private
  104. 1 def cached_strings_data
  105. 1 source_directory = cached_config['source_directory'] || 'src/main'
  106. 1 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  107. 1 strings_file = File.join(layouts_dir, 'Resources', 'strings.json')
  108. 1 return {} unless File.exist?(strings_file)
  109. begin
  110. 1 JSON.parse(File.read(strings_file))
  111. rescue JSON::ParserError
  112. {}
  113. end
  114. end
  115. 1 def cached_colors_data
  116. 2 source_directory = cached_config['source_directory'] || 'src/main'
  117. 2 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  118. 2 colors_file = File.join(layouts_dir, 'Resources', 'colors.json')
  119. 2 return {} unless File.exist?(colors_file)
  120. begin
  121. 2 JSON.parse(File.read(colors_file))
  122. rescue JSON::ParserError
  123. {}
  124. end
  125. end
  126. 1 def find_string_key(text, config, source_path)
  127. 1 strings_data = cached_strings_data
  128. # First, check if the text itself is a resource key (snake_case like "login_password")
  129. # This handles the case where JSON has text: "login_password" which should resolve to R.string.login_password
  130. 1 if text.match?(/^[a-z]+(_[a-z0-9]+)+$/)
  131. strings_data.each do |file_prefix, file_strings|
  132. next unless file_strings.is_a?(Hash)
  133. file_strings.each do |key, _value|
  134. full_key = "#{file_prefix}_#{key}"
  135. if full_key == text
  136. # Text matches an existing resource key directly
  137. return text
  138. end
  139. end
  140. end
  141. end
  142. # Search through all file prefixes for matching values
  143. 1 strings_data.each do |file_prefix, file_strings|
  144. 1 next unless file_strings.is_a?(Hash)
  145. 1 file_strings.each do |key, value|
  146. 1 if value == text
  147. # Return the full key with prefix
  148. 1 return "#{file_prefix}_#{key}"
  149. end
  150. end
  151. end
  152. nil
  153. end
  154. 1 def find_color_key(color, config, source_path)
  155. 2 colors_data = cached_colors_data
  156. # First check if the color itself is a key in colors.json
  157. 2 if colors_data.has_key?(color)
  158. 1 return color
  159. end
  160. # If it's a hex color, normalize and search by value
  161. 1 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  162. 1 normalized_color = normalize_color(color)
  163. # Search through colors by value
  164. 1 colors_data.each do |key, value|
  165. 1 if normalize_color(value) == normalized_color
  166. 1 return key
  167. end
  168. end
  169. end
  170. # Also check colors.xml for predefined Android colors
  171. # These are colors that might be defined in colors.xml but not in colors.json
  172. colors_xml_path = File.join(source_path, config['source_directory'] || 'src/main', 'res/values/colors.xml')
  173. if File.exist?(colors_xml_path)
  174. # Quick check - if the color name exists in colors.xml
  175. # we'll assume it's available (proper check would parse XML)
  176. xml_content = File.read(colors_xml_path)
  177. if xml_content.include?("name='#{color}'") || xml_content.include?("name=\"#{color}\"")
  178. return color
  179. end
  180. end
  181. nil
  182. end
  183. 1 def normalize_color(color)
  184. 2 return nil unless color.is_a?(String)
  185. # Remove # if present and convert to lowercase
  186. 2 color.sub(/^#/, '').downcase
  187. end
  188. 1 def quote(text)
  189. # Escape special characters properly
  190. 234 escaped = text.to_s.gsub('\\', '\\\\\\\\')
  191. .gsub('"', '\\"')
  192. .gsub("\n", '\\n')
  193. .gsub("\r", '\\r')
  194. .gsub("\t", '\\t')
  195. 234 "\"#{escaped}\""
  196. end
  197. end
  198. end
  199. end
  200. end
  201. end

lib/compose/helpers/visibility_helper.rb

100.0% lines covered

31 relevant lines. 31 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class VisibilityHelper
  6. 1 def self.wrap_with_visibility(json_data, component_code, depth, required_imports)
  7. 67 visibility_result = ModifierBuilder.build_visibility(json_data, required_imports)
  8. 67 visibility_info = visibility_result[:visibility_info]
  9. # If no visibility attributes, return the component as-is
  10. 67 return component_code if visibility_info.empty?
  11. # Build VisibilityWrapper
  12. 5 wrapper_code = indent("VisibilityWrapper(", depth)
  13. # Add visibility parameters
  14. 5 if visibility_info[:visibility_binding]
  15. 1 wrapper_code += "\n" + indent("visibility = #{visibility_info[:visibility_binding]},", depth + 1)
  16. 4 elsif visibility_info[:visibility]
  17. 2 wrapper_code += "\n" + indent("visibility = \"#{visibility_info[:visibility]}\",", depth + 1)
  18. end
  19. 5 if visibility_info[:hidden_binding]
  20. 1 wrapper_code += "\n" + indent("hidden = #{visibility_info[:hidden_binding]},", depth + 1)
  21. 4 elsif visibility_info[:hidden]
  22. 1 wrapper_code += "\n" + indent("hidden = true,", depth + 1)
  23. end
  24. 5 wrapper_code += "\n" + indent(") {", depth)
  25. 5 wrapper_code += "\n" + component_code
  26. 5 wrapper_code += "\n" + indent("}", depth)
  27. 5 wrapper_code
  28. end
  29. 1 def self.should_skip_render?(json_data)
  30. # Check if component should not be rendered at all (static gone/hidden)
  31. 67 return true if json_data['visibility'] == 'gone' && !json_data['visibility'].to_s.include?('@{')
  32. 66 return true if json_data['hidden'] == true && !json_data['hidden'].to_s.include?('@{')
  33. 65 false
  34. end
  35. 1 private
  36. 1 def self.indent(text, level)
  37. 20 return text if level == 0
  38. 5 spaces = ' ' * level
  39. 5 text.split("\n").map { |line|
  40. 5 line.empty? ? line : spaces + line
  41. }.join("\n")
  42. end
  43. end
  44. end
  45. end
  46. end

lib/compose/setup/compose_setup.rb

45.3% lines covered

117 relevant lines. 53 lines covered and 64 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Setup
  9. 1 class ComposeSetup
  10. 1 def initialize(project_file_path = nil)
  11. 11 @project_file_path = project_file_path
  12. 11 @config = Core::ConfigManager.load_config
  13. 11 @source_path = Core::ProjectFinder.get_full_source_path
  14. 11 @package_name = Core::ProjectFinder.package_name
  15. end
  16. 1 def run_full_setup
  17. 2 puts "Setting up Compose project..."
  18. # Create directory structure
  19. 2 create_directory_structure
  20. # Copy base files
  21. 2 copy_base_files
  22. # Create hotloader config
  23. 2 create_hotloader_config
  24. # Setup network security for hot reload
  25. 2 setup_network_security
  26. # Update build.gradle
  27. 2 update_build_gradle
  28. 2 puts "Compose setup complete!"
  29. end
  30. 1 private
  31. 1 def create_directory_structure
  32. 1 puts "Creating directory structure..."
  33. # Get source directory from config
  34. 1 source_dir = @config['source_directory'] || 'src/main'
  35. directories = [
  36. 1 File.join(source_dir, 'assets/Layouts'),
  37. File.join(source_dir, 'assets/Styles'),
  38. package_path('ui/theme')
  39. # data, viewmodels, views directories will be created by g view command
  40. ]
  41. 1 directories.each do |dir|
  42. # All paths should be relative to the project root
  43. 3 FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
  44. 3 puts " Created: #{dir}"
  45. end
  46. end
  47. 1 def copy_base_files
  48. puts "Creating base files..."
  49. # Create theme file
  50. create_theme_file
  51. # Create MainActivity with Compose setup
  52. create_main_activity
  53. end
  54. 1 def create_theme_file
  55. theme_path = File.join(package_path('ui/theme'), 'Theme.kt')
  56. content = <<~KOTLIN
  57. package #{@package_name}.ui.theme
  58. import androidx.compose.foundation.isSystemInDarkTheme
  59. import androidx.compose.material3.*
  60. import androidx.compose.runtime.Composable
  61. import androidx.compose.ui.graphics.Color
  62. private val LightColorScheme = lightColorScheme(
  63. primary = Color(0xFF6200EE),
  64. onPrimary = Color.White,
  65. secondary = Color(0xFF03DAC6),
  66. onSecondary = Color.Black,
  67. background = Color(0xFFF5F5F5),
  68. onBackground = Color.Black,
  69. surface = Color.White,
  70. onSurface = Color.Black,
  71. )
  72. private val DarkColorScheme = darkColorScheme(
  73. primary = Color(0xFFBB86FC),
  74. onPrimary = Color.Black,
  75. secondary = Color(0xFF03DAC6),
  76. onSecondary = Color.Black,
  77. background = Color(0xFF121212),
  78. onBackground = Color.White,
  79. surface = Color(0xFF121212),
  80. onSurface = Color.White,
  81. )
  82. @Composable
  83. fun KotlinJsonUITheme(
  84. darkTheme: Boolean = isSystemInDarkTheme(),
  85. content: @Composable () -> Unit
  86. ) {
  87. val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
  88. MaterialTheme(
  89. colorScheme = colorScheme,
  90. typography = Typography(),
  91. content = content
  92. )
  93. }
  94. KOTLIN
  95. File.write(theme_path, content)
  96. puts " Created: Theme.kt"
  97. end
  98. 1 def create_main_activity
  99. source_dir = @config['source_directory'] || 'src/main'
  100. package_dirs = @package_name.gsub('.', '/')
  101. activity_path = File.join(source_dir, "kotlin/#{package_dirs}", 'MainActivity.kt')
  102. content = <<~KOTLIN
  103. package #{@package_name}
  104. import android.os.Bundle
  105. import androidx.activity.ComponentActivity
  106. import androidx.activity.compose.setContent
  107. import androidx.compose.foundation.layout.fillMaxSize
  108. import androidx.compose.material3.*
  109. import androidx.compose.runtime.*
  110. import androidx.compose.ui.Modifier
  111. import com.kotlinjsonui.core.DynamicModeManager
  112. import #{@package_name}.ui.theme.KotlinJsonUITheme
  113. import #{@package_name}.views.splash.SplashView
  114. class MainActivity : ComponentActivity() {
  115. override fun onCreate(savedInstanceState: Bundle?) {
  116. super.onCreate(savedInstanceState)
  117. setContent {
  118. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  119. KotlinJsonUITheme {
  120. Surface(
  121. modifier = Modifier.fillMaxSize(),
  122. color = MaterialTheme.colorScheme.background
  123. ) {
  124. key(isDynamicModeEnabled) {
  125. SplashView()
  126. }
  127. }
  128. }
  129. }
  130. }
  131. }
  132. KOTLIN
  133. File.write(activity_path, content) unless File.exist?(activity_path)
  134. puts " Created: MainActivity.kt" unless File.exist?(activity_path)
  135. end
  136. 1 def update_build_gradle
  137. 1 puts "Updating build.gradle..."
  138. 1 gradle_file = find_app_gradle_file
  139. 1 return unless gradle_file
  140. content = File.read(gradle_file)
  141. # Check if Compose is already configured
  142. unless content.include?('compose')
  143. puts " Adding Compose dependencies to build.gradle..."
  144. # Add compose to buildFeatures
  145. unless content.include?('buildFeatures')
  146. content.gsub!(/android\s*\{/, "android {\n buildFeatures {\n compose = true\n }")
  147. end
  148. # Add compose options
  149. unless content.include?('composeOptions')
  150. content.gsub!(/android\s*\{/, "android {\n composeOptions {\n kotlinCompilerExtensionVersion = \"1.5.7\"\n }")
  151. end
  152. # Add Compose BOM
  153. unless content.include?('androidx.compose:compose-bom')
  154. dependencies_section = content.match(/dependencies\s*\{(.*?)\}/m)
  155. if dependencies_section
  156. new_deps = <<~GRADLE
  157. implementation(platform("androidx.compose:compose-bom:2023.10.01"))
  158. implementation("androidx.compose.ui:ui")
  159. implementation("androidx.compose.ui:ui-tooling-preview")
  160. implementation("androidx.compose.material3:material3")
  161. implementation("androidx.compose.runtime:runtime")
  162. implementation("androidx.activity:activity-compose:1.8.0")
  163. GRADLE
  164. content.gsub!(/dependencies\s*\{/, "dependencies {\n#{new_deps}")
  165. end
  166. end
  167. File.write(gradle_file, content)
  168. puts " Updated build.gradle with Compose dependencies"
  169. else
  170. puts " Compose already configured in build.gradle"
  171. end
  172. end
  173. 1 def create_hotloader_config
  174. puts "Creating hotloader configuration..."
  175. # Determine the correct project directory
  176. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  177. # Check if we're in sample-app
  178. if File.exist?(File.join(project_root, 'sample-app'))
  179. assets_dir = File.join(project_root, 'sample-app', 'src', 'main', 'assets')
  180. else
  181. source_dir = @config['source_directory'] || 'src/main'
  182. assets_dir = File.join(project_root, source_dir, 'assets')
  183. end
  184. FileUtils.mkdir_p(assets_dir)
  185. # Get IP from config or detect it
  186. ip = if @config['hotloader'] && @config['hotloader']['ip']
  187. @config['hotloader']['ip']
  188. else
  189. get_local_ip || '10.0.2.2' # Default to Android emulator IP
  190. end
  191. port = if @config['hotloader'] && @config['hotloader']['port']
  192. @config['hotloader']['port']
  193. else
  194. 8081
  195. end
  196. # Create hotloader.json
  197. hotloader_config_path = File.join(assets_dir, 'hotloader.json')
  198. hotloader_config = {
  199. 'ip' => ip,
  200. 'port' => port,
  201. 'enabled' => false, # Default to disabled for initial setup
  202. 'websocket_endpoint' => "ws://#{ip}:#{port}",
  203. 'http_endpoint' => "http://#{ip}:#{port}"
  204. }
  205. File.write(hotloader_config_path, JSON.pretty_generate(hotloader_config))
  206. puts " Created: hotloader.json (IP: #{ip}:#{port})"
  207. end
  208. 1 def setup_network_security
  209. puts "Setting up network security for hot reload..."
  210. # Determine the correct project directory
  211. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  212. # Check if we're in sample-app
  213. if File.exist?(File.join(project_root, 'sample-app'))
  214. res_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'xml')
  215. debug_dir = File.join(project_root, 'sample-app', 'src', 'debug')
  216. else
  217. source_dir = @config['source_directory'] || 'src/main'
  218. res_dir = File.join(project_root, source_dir, 'res', 'xml')
  219. # Derive debug path from source_dir (e.g., 'app/src/main' -> 'app/src/debug')
  220. debug_dir = File.join(project_root, source_dir.sub('/main', '/debug'))
  221. end
  222. # Create network security config
  223. FileUtils.mkdir_p(res_dir)
  224. network_config_path = File.join(res_dir, 'network_security_config.xml')
  225. network_config = <<~XML
  226. <?xml version="1.0" encoding="utf-8"?>
  227. <network-security-config>
  228. <!-- Allow cleartext traffic for hot reload development server -->
  229. <domain-config cleartextTrafficPermitted="true">
  230. <!-- Android emulator localhost -->
  231. <domain includeSubdomains="true">10.0.2.2</domain>
  232. <!-- Common local network ranges -->
  233. <domain includeSubdomains="true">localhost</domain>
  234. <domain includeSubdomains="true">127.0.0.1</domain>
  235. <!-- Local network IPs (adjust as needed) -->
  236. <domain includeSubdomains="true">192.168.0.0/16</domain>
  237. <domain includeSubdomains="true">192.168.1.0/24</domain>
  238. <domain includeSubdomains="true">192.168.3.0/24</domain>
  239. <domain includeSubdomains="true">10.0.0.0/8</domain>
  240. </domain-config>
  241. <!-- Default configuration for production -->
  242. <base-config cleartextTrafficPermitted="false">
  243. <trust-anchors>
  244. <certificates src="system" />
  245. </trust-anchors>
  246. </base-config>
  247. </network-security-config>
  248. XML
  249. File.write(network_config_path, network_config)
  250. puts " Created: network_security_config.xml"
  251. # Create debug-specific AndroidManifest.xml with both network config and cleartext traffic
  252. FileUtils.mkdir_p(debug_dir)
  253. debug_manifest_path = File.join(debug_dir, 'AndroidManifest.xml')
  254. debug_manifest = <<~XML
  255. <?xml version="1.0" encoding="utf-8"?>
  256. <manifest xmlns:android="http://schemas.android.com/apk/res/android"
  257. xmlns:tools="http://schemas.android.com/tools">
  258. <!-- Internet permission for hot reload -->
  259. <uses-permission android:name="android.permission.INTERNET" />
  260. <!-- Debug-only configuration for hot reload -->
  261. <application
  262. android:networkSecurityConfig="@xml/network_security_config"
  263. android:usesCleartextTraffic="true"
  264. tools:targetApi="31">
  265. </application>
  266. </manifest>
  267. XML
  268. File.write(debug_manifest_path, debug_manifest)
  269. puts " Created: debug/AndroidManifest.xml with cleartext traffic enabled for debug builds only"
  270. end
  271. 1 def get_local_ip
  272. # Try to get WiFi IP first (common interface names)
  273. 1 require 'socket'
  274. 1 Socket.ip_address_list.each do |addr|
  275. 9 if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  276. 1 return addr.ip_address
  277. end
  278. end
  279. nil
  280. rescue
  281. nil
  282. end
  283. 1 def package_path(subpath)
  284. 2 source_dir = @config['source_directory'] || 'src/main'
  285. 2 package_dirs = @package_name.gsub('.', '/')
  286. 2 File.join(source_dir, "kotlin/#{package_dirs}/#{subpath}")
  287. end
  288. 1 def find_app_gradle_file
  289. # Look for app/build.gradle or app/build.gradle.kts
  290. 4 candidates = [
  291. 'app/build.gradle.kts',
  292. 'app/build.gradle',
  293. 'build.gradle.kts',
  294. 'build.gradle'
  295. ]
  296. 4 project_root = Core::ProjectFinder.project_dir || Dir.pwd
  297. 4 candidates.each do |candidate|
  298. 11 path = File.join(project_root, candidate)
  299. 11 return path if File.exist?(path)
  300. end
  301. nil
  302. end
  303. end
  304. end
  305. end
  306. end

lib/compose/style_loader.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../core/config_manager'
  4. 1 require_relative '../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 class StyleLoader
  8. 1 class << self
  9. 1 def load_and_merge(json_data)
  10. 41 return json_data unless json_data.is_a?(Hash)
  11. # Load style if specified
  12. 40 if json_data['style']
  13. 4 style_data = load_style(json_data['style'])
  14. 4 if style_data
  15. # Merge style data with component data
  16. # Component data takes precedence over style data
  17. 3 merged_data = style_data.merge(json_data)
  18. # Remove the style key from the merged data
  19. 3 merged_data.delete('style')
  20. 3 json_data = merged_data
  21. end
  22. end
  23. # Process children recursively
  24. 40 if json_data['child']
  25. 13 if json_data['child'].is_a?(Array)
  26. 23 json_data['child'] = json_data['child'].map { |child| load_and_merge(child) }
  27. else
  28. 2 json_data['child'] = load_and_merge(json_data['child'])
  29. end
  30. end
  31. # Process includes
  32. 40 if json_data['include']
  33. 2 json_data = process_include(json_data)
  34. end
  35. 40 json_data
  36. end
  37. 1 private
  38. 1 def load_style(style_name)
  39. 4 config = Core::ConfigManager.load_config
  40. 4 project_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  41. 4 source_dir = config['source_directory'] || 'src/main'
  42. 4 source_path = File.join(project_path, source_dir)
  43. 4 styles_dir = File.join(source_path, config['styles_directory'] || 'assets/Styles')
  44. 4 style_file = File.join(styles_dir, "#{style_name}.json")
  45. 4 return nil unless File.exist?(style_file)
  46. begin
  47. 4 style_content = File.read(style_file)
  48. 4 style_data = JSON.parse(style_content)
  49. # Recursively load and merge styles in the style file
  50. 3 load_and_merge(style_data)
  51. 1 rescue JSON::ParserError => e
  52. 1 puts "Warning: Failed to parse style file #{style_file}: #{e.message}"
  53. 1 nil
  54. end
  55. end
  56. 1 def process_include(json_data)
  57. # For Compose generation, don't expand includes inline
  58. # They should be handled as component calls in compose_builder
  59. 2 json_data
  60. end
  61. end
  62. end
  63. end
  64. end

lib/core/attribute_validator.rb

76.53% lines covered

277 relevant lines. 212 lines covered and 65 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 module KjuiTools
  4. 1 module Core
  5. # Validates JSON component attributes against defined schemas
  6. # Used by both XML and Compose converters
  7. 1 class AttributeValidator
  8. 1 attr_reader :definitions, :warnings, :infos
  9. 1 attr_accessor :mode, :styles_dir
  10. # Valid modes for this platform
  11. 1 MODES = [:xml, :compose, :dynamic, :all].freeze
  12. # Current platform identifier
  13. 1 PLATFORM = 'kotlin'.freeze
  14. # All supported platforms across JsonUI libraries
  15. 1 ALL_PLATFORMS = ['swift', 'kotlin', 'react'].freeze
  16. 1 def initialize(mode = :all, styles_dir = nil)
  17. 121 @definitions = load_definitions
  18. 121 @warnings = []
  19. 121 @infos = []
  20. 121 @mode = mode
  21. 121 @styles_dir = styles_dir
  22. 121 @styles_cache = {}
  23. end
  24. # Validate a component and return warnings
  25. # @param component [Hash] The component to validate
  26. # @param component_type [String] The type of component (e.g., "Label", "TextField")
  27. # @param parent_orientation [String] The parent's orientation ('horizontal' or 'vertical')
  28. # @return [Array<String>] Array of warning messages
  29. 1 def validate(component, component_type = nil, parent_orientation = nil)
  30. 89 @warnings = []
  31. 89 @infos = []
  32. # Merge style attributes before validation
  33. 89 merged_component = merge_style_attributes(component)
  34. 89 type = component_type || merged_component['type']
  35. 89 return @warnings unless type
  36. # Get valid attributes for this component type
  37. 89 valid_attrs = get_valid_attributes(type)
  38. # Check each attribute in the merged component
  39. 89 merged_component.each do |key, value|
  40. # Skip internal/structural attributes (including _ prefixed internal flags)
  41. 384 next if key == 'type' || key == 'child' || key == 'children' || key.start_with?('_')
  42. 292 if valid_attrs.key?(key)
  43. 287 attr_def = valid_attrs[key]
  44. # Check platform compatibility first
  45. 287 if platform_compatible?(attr_def)
  46. # Check mode compatibility
  47. 285 if mode_compatible?(attr_def)
  48. # Validate attribute value
  49. 285 validate_attribute(key, value, attr_def, type)
  50. else
  51. # Attribute not supported in current mode - log as info
  52. add_mode_info(key, attr_def, type)
  53. end
  54. else
  55. # Attribute for other platform - log as info
  56. 2 add_platform_info(key, attr_def, type)
  57. end
  58. else
  59. # Unknown attribute
  60. 5 add_warning("Unknown attribute '#{key}' for component type '#{type}'")
  61. end
  62. end
  63. # Check for required attributes (only for current platform)
  64. 89 valid_attrs.each do |attr_name, attr_def|
  65. 13249 next unless platform_compatible?(attr_def)
  66. 8489 if attr_def['required'] && !merged_component.key?(attr_name)
  67. # Skip width/height required check if weight is set and parent orientation allows it
  68. 48 next if skip_dimension_required?(attr_name, merged_component, parent_orientation)
  69. 48 add_warning("Required attribute '#{attr_name}' is missing for component type '#{type}'")
  70. end
  71. end
  72. # Check for conflicting attributes
  73. 89 check_spacing_gravity_conflict(merged_component, type)
  74. 89 @warnings
  75. end
  76. # Print all warnings to console
  77. 1 def print_warnings
  78. @warnings.each do |warning|
  79. puts "\e[33m⚠️ [KJUI Warning] #{warning}\e[0m"
  80. end
  81. end
  82. # Print all info messages to console
  83. 1 def print_infos
  84. @infos.each do |info|
  85. puts "\e[36mℹ️ [KJUI Info] #{info}\e[0m"
  86. end
  87. end
  88. # Check if there are any warnings
  89. 1 def has_warnings?
  90. 2 !@warnings.empty?
  91. end
  92. # Check if there are any info messages
  93. 1 def has_infos?
  94. !@infos.empty?
  95. end
  96. 1 private
  97. 1 def load_definitions
  98. 121 definitions_path = File.join(File.dirname(__FILE__), 'attribute_definitions.json')
  99. 121 base_definitions = if File.exist?(definitions_path)
  100. 121 JSON.parse(File.read(definitions_path))
  101. else
  102. puts "\e[31m[KJUI Error] attribute_definitions.json not found at #{definitions_path}\e[0m"
  103. {}
  104. end
  105. # Load and merge extension attribute definitions
  106. 121 extension_definitions = load_extension_definitions
  107. 121 merge_definitions(base_definitions, extension_definitions)
  108. end
  109. # Load extension attribute definitions from the extensions directory
  110. 1 def load_extension_definitions
  111. 121 extension_defs = {}
  112. # Check for extension definitions in various locations
  113. extension_paths = [
  114. # Main KotlinJsonUI structure
  115. 121 File.join(Dir.pwd, 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions'),
  116. # Test app structure
  117. File.join(Dir.pwd, 'app', 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions')
  118. ]
  119. 121 extension_paths.each do |ext_dir|
  120. 242 next unless File.directory?(ext_dir)
  121. 118 Dir.glob(File.join(ext_dir, '*.json')).each do |file|
  122. begin
  123. 5 component_defs = JSON.parse(File.read(file))
  124. 5 extension_defs.merge!(component_defs)
  125. rescue JSON::ParserError => e
  126. puts "\e[33m[KJUI Warning] Failed to parse extension definition #{file}: #{e.message}\e[0m"
  127. end
  128. end
  129. end
  130. 121 extension_defs
  131. end
  132. # Merge extension definitions into base definitions
  133. 1 def merge_definitions(base, extensions)
  134. 121 extensions.each do |key, value|
  135. 5 if base.key?(key) && base[key].is_a?(Hash) && value.is_a?(Hash)
  136. # Merge attributes for existing component types
  137. base[key] = base[key].merge(value)
  138. else
  139. # Add new component type definitions
  140. 5 base[key] = value
  141. end
  142. end
  143. 121 base
  144. end
  145. # Get valid attributes for a component type (common + type-specific)
  146. 1 def get_valid_attributes(type)
  147. 89 attrs = {}
  148. # Add common attributes
  149. 89 attrs.merge!(@definitions['common'] || {})
  150. # Map component type to definition key
  151. 89 def_key = map_type_to_definition(type)
  152. # Add type-specific attributes
  153. 89 if @definitions[def_key]
  154. 89 attrs.merge!(@definitions[def_key])
  155. end
  156. 89 attrs
  157. end
  158. # Map JSON type to definition key
  159. 1 def map_type_to_definition(type)
  160. 89 case type
  161. when 'Label', 'Text'
  162. 17 'Label'
  163. when 'TextField', 'EditText'
  164. 11 'TextField'
  165. when 'TextView', 'MultiLineEditText'
  166. 'TextView'
  167. when 'Button'
  168. 1 'Button'
  169. when 'Image', 'ImageView'
  170. 1 'Image'
  171. when 'NetworkImage', 'NetworkImageView'
  172. 'NetworkImage'
  173. when 'CircleImage', 'CircleImageView'
  174. 'CircleImage'
  175. when 'SelectBox', 'Spinner', 'DatePicker'
  176. 1 'SelectBox'
  177. when 'Toggle', 'Switch'
  178. 7 'Toggle'
  179. when 'CheckBox', 'Check'
  180. 5 type == 'CheckBox' ? 'CheckBox' : 'Check'
  181. when 'Radio', 'RadioButton', 'RadioGroup'
  182. 1 'Radio'
  183. when 'Segment', 'SegmentedControl', 'TabLayout'
  184. 'Segment'
  185. when 'Slider', 'SeekBar'
  186. 'Slider'
  187. when 'Progress', 'ProgressBar'
  188. 'Progress'
  189. when 'Indicator', 'ActivityIndicator'
  190. 'Indicator'
  191. when 'View', 'Container', 'SafeAreaView', 'LinearLayout', 'RelativeLayout', 'FrameLayout',
  192. 'VStack', 'HStack', 'ZStack', 'Column', 'Row', 'Box'
  193. 38 'View'
  194. when 'ScrollView', 'Scroll'
  195. 1 'ScrollView'
  196. when 'Collection', 'CollectionView', 'RecyclerView', 'LazyGrid', 'Grid'
  197. 2 'Collection'
  198. when 'Table', 'TableView', 'ListView', 'LazyColumn'
  199. 'Table'
  200. when 'GradientView'
  201. 'GradientView'
  202. when 'Blur', 'BlurView'
  203. 'Blur'
  204. when 'IconLabel'
  205. 'IconLabel'
  206. when 'Web', 'WebView'
  207. 'Web'
  208. when 'TabView'
  209. 'TabView'
  210. when 'ConstraintLayout'
  211. 'View'
  212. else
  213. 4 type
  214. end
  215. end
  216. # Validate a single attribute value
  217. 1 def validate_attribute(name, value, definition, component_type, path = nil)
  218. 290 return unless definition
  219. 290 current_path = path ? "#{path}.#{name}" : name
  220. # Check for invalid binding syntax
  221. 290 check_invalid_binding_syntax(value, current_path, component_type)
  222. # Check if value is a binding expression
  223. 290 is_binding = value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  224. # Skip validation for binding expressions
  225. 290 return if is_binding
  226. # Check type
  227. 259 expected_types = Array(definition['type'])
  228. 259 actual_type = get_value_type(value)
  229. 259 unless type_matches?(actual_type, expected_types, value, definition)
  230. 3 add_warning("Attribute '#{current_path}' in '#{component_type}' expects #{format_expected_types(expected_types)}, got #{actual_type}")
  231. 3 return # Don't validate nested properties if type is wrong
  232. end
  233. # Check enum values
  234. 256 if definition['enum']
  235. 19 validate_enum_value(value, definition['enum'], current_path, component_type)
  236. end
  237. # Check min/max for numbers
  238. 256 if actual_type == 'number'
  239. 37 if definition['min'] && value < definition['min']
  240. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is less than minimum #{definition['min']}")
  241. end
  242. 37 if definition['max'] && value > definition['max']
  243. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is greater than maximum #{definition['max']}")
  244. end
  245. end
  246. # Validate nested object properties
  247. 256 if actual_type == 'object' && definition['properties']
  248. 2 validate_nested_object(value, definition['properties'], component_type, current_path)
  249. end
  250. # Validate array items
  251. 256 if actual_type == 'array' && definition['items']
  252. validate_array_items(value, definition['items'], component_type, current_path)
  253. end
  254. end
  255. # Validate enum value (supports both single values and arrays)
  256. 1 def validate_enum_value(value, enum_values, path, component_type)
  257. 19 if value.is_a?(Array)
  258. # For array values, check each element
  259. 11 invalid_values = value.reject { |v| enum_values.include?(v) }
  260. 4 unless invalid_values.empty?
  261. 2 add_warning("Attribute '#{path}' in '#{component_type}' has invalid value(s) '#{invalid_values.inspect}'. Valid values: #{enum_values.join(', ')}")
  262. end
  263. else
  264. # For single values
  265. 15 unless enum_values.include?(value)
  266. 4 add_warning("Attribute '#{path}' in '#{component_type}' has invalid value '#{value}'. Valid values: #{enum_values.join(', ')}")
  267. end
  268. end
  269. end
  270. # Format expected types for error messages
  271. 1 def format_expected_types(expected_types)
  272. 3 formatted = expected_types.map do |type|
  273. 6 if type.is_a?(Hash) && type['enum']
  274. 1 "enum(#{type['enum'].join(', ')})"
  275. else
  276. 5 type
  277. end
  278. end
  279. 3 formatted.join(' or ')
  280. end
  281. # Validate nested object properties
  282. 1 def validate_nested_object(obj, properties, component_type, path)
  283. 2 return unless obj.is_a?(Hash)
  284. 2 obj.each do |key, value|
  285. 5 if properties.key?(key)
  286. 5 validate_attribute(key, value, properties[key], component_type, path)
  287. else
  288. add_warning("Unknown property '#{path}.#{key}' in '#{component_type}'")
  289. end
  290. end
  291. end
  292. # Validate array items
  293. 1 def validate_array_items(arr, item_def, component_type, path)
  294. return unless arr.is_a?(Array)
  295. arr.each_with_index do |item, index|
  296. item_path = "#{path}[#{index}]"
  297. if item_def['type'] == 'object' && item_def['properties']
  298. if item.is_a?(Hash)
  299. validate_nested_object(item, item_def['properties'], component_type, item_path)
  300. else
  301. add_warning("#{item_path} in '#{component_type}' expects object, got #{get_value_type(item)}")
  302. end
  303. else
  304. # Simple type validation for array items
  305. expected_types = Array(item_def['type'])
  306. actual_type = get_value_type(item)
  307. unless type_matches?(actual_type, expected_types, item, item_def)
  308. add_warning("#{item_path} in '#{component_type}' expects #{expected_types.join(' or ')}, got #{actual_type}")
  309. end
  310. end
  311. end
  312. end
  313. 1 def get_value_type(value)
  314. 259 case value
  315. when String
  316. 201 'string'
  317. when Integer, Float
  318. 37 'number'
  319. when TrueClass, FalseClass
  320. 12 'boolean'
  321. when Array
  322. 7 'array'
  323. when Hash
  324. 2 'object'
  325. when NilClass
  326. 'null'
  327. else
  328. 'unknown'
  329. end
  330. end
  331. 1 def type_matches?(actual, expected_types, value, definition = nil)
  332. 259 expected_types.any? do |expected|
  333. 390 case expected
  334. when 'string'
  335. 84 actual == 'string'
  336. when 'number'
  337. 162 actual == 'number'
  338. when 'boolean'
  339. 12 actual == 'boolean'
  340. when 'array'
  341. 7 actual == 'array'
  342. when 'object'
  343. 2 actual == 'object'
  344. when 'binding'
  345. # binding type requires @{propertyName} format
  346. 2 actual == 'string' && value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  347. when 'any'
  348. true
  349. when Hash
  350. # Handle enum type definition: {"enum": [...]}
  351. 121 if expected['enum']
  352. 121 if actual == 'string'
  353. 121 expected['enum'].include?(value)
  354. elsif actual == 'array'
  355. # For array values, check if all elements are in enum
  356. value.is_a?(Array) && value.all? { |v| expected['enum'].include?(v) }
  357. else
  358. false
  359. end
  360. else
  361. false
  362. end
  363. else
  364. # For union types or special cases
  365. actual == expected
  366. end
  367. end
  368. end
  369. 1 def add_warning(message)
  370. 68 @warnings << message unless @warnings.include?(message)
  371. end
  372. 1 def add_info(message)
  373. 2 @infos << message unless @infos.include?(message)
  374. end
  375. # Check for invalid binding syntax (starts with @{ but doesn't end with })
  376. 1 def check_invalid_binding_syntax(value, path, component_type)
  377. 290 return unless value.is_a?(String)
  378. 232 return unless value.start_with?('@{')
  379. 35 return if value.end_with?('}')
  380. 4 add_warning("Attribute '#{path}' in '#{component_type}' has invalid binding syntax (starts with '@{' but doesn't end with '}')")
  381. end
  382. # Check for conflicting spacing and gravity attributes
  383. # Using both spacing and gravity together can cause unexpected layout behavior
  384. 1 def check_spacing_gravity_conflict(component, component_type)
  385. 89 has_spacing = component.key?('spacing') || component.key?('distribution')
  386. 89 has_gravity = component.key?('gravity')
  387. 89 if has_spacing && has_gravity
  388. add_warning("Component '#{component_type}' has both 'spacing'/'distribution' and 'gravity' set. This combination may cause unexpected layout behavior. Consider using only one of these attributes.")
  389. end
  390. end
  391. # Check if width/height required warning should be skipped
  392. # When weight is set, the dimension in the parent's orientation direction is not required
  393. # - parent orientation: horizontal -> width not required if weight is set
  394. # - parent orientation: vertical -> height not required if weight is set
  395. 1 def skip_dimension_required?(attr_name, component, parent_orientation)
  396. 48 return false unless component.key?('weight')
  397. return false unless %w[width height].include?(attr_name)
  398. case parent_orientation
  399. when 'horizontal'
  400. # In horizontal layout, weight determines width
  401. attr_name == 'width'
  402. when 'vertical'
  403. # In vertical layout, weight determines height
  404. attr_name == 'height'
  405. else
  406. # Default orientation is vertical, so height is determined by weight
  407. attr_name == 'height'
  408. end
  409. end
  410. # Check if attribute is compatible with current platform
  411. # Attributes with platform specified for other platforms are silently skipped
  412. 1 def platform_compatible?(attr_def)
  413. 13536 return true unless attr_def['platform']
  414. 4762 attr_platforms = Array(attr_def['platform'])
  415. 4762 attr_platforms.include?(PLATFORM) || attr_platforms.include?('all')
  416. end
  417. # Check if attribute is compatible with current mode
  418. 1 def mode_compatible?(attr_def)
  419. 285 return true if @mode == :all
  420. 17 return true unless attr_def['mode']
  421. 4 attr_modes = Array(attr_def['mode'])
  422. 4 attr_modes.include?(@mode.to_s) || attr_modes.include?('all')
  423. end
  424. # Add info for mode-incompatible attribute (not an error, just informational)
  425. 1 def add_mode_info(attr_name, attr_def, component_type)
  426. attr_modes = Array(attr_def['mode'])
  427. mode_str = attr_modes.map { |m| m.capitalize }.join('/')
  428. current_mode_str = @mode.to_s.capitalize
  429. add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{mode_str} mode (current: #{current_mode_str})")
  430. end
  431. # Add info for platform-specific attribute (not an error, just informational)
  432. 1 def add_platform_info(attr_name, attr_def, component_type)
  433. 2 attr_platforms = Array(attr_def['platform'])
  434. 4 platform_str = attr_platforms.map { |p| p.capitalize }.join('/')
  435. 2 add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{platform_str} platform (current: #{PLATFORM.capitalize})")
  436. end
  437. # Merge style attributes into component for validation
  438. # Style provides base attributes, component attributes override
  439. # @param component [Hash] The component to process
  440. # @return [Hash] Component with style attributes merged
  441. 1 def merge_style_attributes(component)
  442. 89 return component unless component.is_a?(Hash)
  443. 89 return component unless component['style']
  444. 5 style_name = component['style']
  445. 5 style_data = load_style_file(style_name)
  446. 5 return component unless style_data
  447. # Create merged result: style as base, component overrides
  448. 3 component_without_style = component.dup
  449. 3 component_without_style.delete('style')
  450. # If component has type, ignore style's type
  451. 3 style_data_for_merge = style_data.dup
  452. 3 if component_without_style['type']
  453. 3 style_data_for_merge.delete('type')
  454. end
  455. # Deep merge: style as base, component properties override
  456. 3 deep_merge(style_data_for_merge, component_without_style)
  457. end
  458. # Load style file from styles directory
  459. # @param style_name [String] Name of the style file (without .json extension)
  460. # @return [Hash, nil] Parsed style data or nil if not found
  461. 1 def load_style_file(style_name)
  462. 5 return @styles_cache[style_name] if @styles_cache.key?(style_name)
  463. 5 styles_dir = determine_styles_dir
  464. 5 return nil unless styles_dir
  465. 5 style_file = File.join(styles_dir, "#{style_name}.json")
  466. 5 return nil unless File.exist?(style_file)
  467. begin
  468. 3 style_data = JSON.parse(File.read(style_file))
  469. 3 @styles_cache[style_name] = style_data
  470. 3 style_data
  471. rescue JSON::ParserError
  472. nil
  473. end
  474. end
  475. # Determine the styles directory path
  476. # @return [String, nil] Path to styles directory or nil
  477. 1 def determine_styles_dir
  478. 5 return @styles_dir if @styles_dir && Dir.exist?(@styles_dir)
  479. # Try to read from config first
  480. 1 config = load_kjui_config
  481. 1 if config
  482. source_dir = config['source_directory']
  483. styles_dir = config['styles_directory']
  484. if source_dir && styles_dir
  485. config_path = File.join(Dir.pwd, source_dir, styles_dir)
  486. return config_path if Dir.exist?(config_path)
  487. end
  488. end
  489. # Fallback to common locations for Android projects
  490. possible_dirs = [
  491. # Styles inside Layouts directory (common pattern)
  492. 1 File.join(Dir.pwd, 'src', 'main', 'assets', 'Layouts', 'Styles'),
  493. File.join(Dir.pwd, 'app', 'src', 'main', 'assets', 'Layouts', 'Styles'),
  494. # Styles at assets root
  495. File.join(Dir.pwd, 'src', 'main', 'assets', 'Styles'),
  496. File.join(Dir.pwd, 'app', 'src', 'main', 'assets', 'Styles'),
  497. # Other common locations
  498. File.join(Dir.pwd, 'Styles'),
  499. File.join(Dir.pwd, 'styles'),
  500. File.join(Dir.pwd, 'Layouts', 'Styles'),
  501. File.join(Dir.pwd, 'Layouts', 'styles')
  502. ]
  503. 4 possible_dirs.find { |dir| Dir.exist?(dir) }
  504. end
  505. # Load kjui.config.json if it exists
  506. # @return [Hash, nil] Config hash or nil
  507. 1 def load_kjui_config
  508. 1 config_path = File.join(Dir.pwd, 'kjui.config.json')
  509. 1 return nil unless File.exist?(config_path)
  510. JSON.parse(File.read(config_path))
  511. rescue JSON::ParserError
  512. nil
  513. end
  514. # Deep merge two hashes
  515. # @param hash1 [Hash] Base hash
  516. # @param hash2 [Hash] Override hash
  517. # @return [Hash] Merged hash
  518. 1 def deep_merge(hash1, hash2)
  519. 3 return hash2 if hash1.nil?
  520. 3 return hash1 if hash2.nil?
  521. 3 result = hash1.dup
  522. 3 hash2.each do |key, value|
  523. 4 if result[key].is_a?(Hash) && value.is_a?(Hash)
  524. result[key] = deep_merge(result[key], value)
  525. else
  526. 4 result[key] = value
  527. end
  528. end
  529. 3 result
  530. end
  531. end
  532. end
  533. end

lib/core/binding_validator.rb

84.43% lines covered

122 relevant lines. 103 lines covered and 19 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'set'
  4. 1 module KjuiTools
  5. 1 module Core
  6. # Validates binding expressions in JSON layouts
  7. # Warns when bindings contain business logic that should be in ViewModel
  8. 1 class BindingValidator
  9. 1 attr_reader :warnings
  10. # Patterns that indicate business logic in bindings
  11. BUSINESS_LOGIC_PATTERNS = [
  12. # Ternary operators (Kotlin: if-else expression or ternary-like)
  13. {
  14. 1 pattern: /\?.*:/,
  15. message: "ternary operator (?:) - move condition logic to ViewModel"
  16. },
  17. # Kotlin if expression
  18. {
  19. pattern: /\bif\s*\(/,
  20. message: "if expression - move condition logic to ViewModel"
  21. },
  22. # Kotlin when expression
  23. {
  24. pattern: /\bwhen\s*[({]/,
  25. message: "when expression - move logic to ViewModel"
  26. },
  27. # Comparison operators
  28. {
  29. pattern: /[<>=!]=|[<>]/,
  30. message: "comparison operator - move to ViewModel computed property"
  31. },
  32. # Arithmetic operators (but allow simple negation)
  33. {
  34. pattern: /(?<![a-zA-Z_])[+\/*%]|(?<![a-zA-Z_0-9])-(?![a-zA-Z_0-9}])/,
  35. message: "arithmetic operator - compute value in ViewModel"
  36. },
  37. # Logical operators
  38. {
  39. pattern: /&&|\|\|/,
  40. message: "logical operator (&&, ||) - move logic to ViewModel"
  41. },
  42. # Elvis operator (null coalescing)
  43. {
  44. pattern: /\?:/,
  45. message: "elvis operator (?:) - handle null in ViewModel"
  46. },
  47. # Method calls with arguments (but allow simple property access)
  48. {
  49. pattern: /\.\w+\([^)]+\)/,
  50. message: "method call with arguments - move to ViewModel"
  51. },
  52. # String interpolation
  53. {
  54. pattern: /\$\{|\$[a-zA-Z]/,
  55. message: "string interpolation - compose string in ViewModel"
  56. },
  57. # Array subscript with complex expression
  58. {
  59. pattern: /\[[^\]]*[+\-*\/<>=]/,
  60. message: "complex array subscript - simplify in ViewModel"
  61. },
  62. # Type casting
  63. {
  64. pattern: /\s+as[?\s]+\w+/,
  65. message: "type casting - handle type conversion in ViewModel"
  66. },
  67. # Not-null assertion
  68. {
  69. pattern: /!!/,
  70. message: "not-null assertion (!!) - handle nullability safely in ViewModel"
  71. },
  72. # Lambda expressions
  73. {
  74. pattern: /\{[^}]*->[^}]*\}/,
  75. message: "lambda expression - move to ViewModel"
  76. },
  77. # Range operators
  78. {
  79. pattern: /\.\.|\s+until\s+|\s+downTo\s+/,
  80. message: "range operator - create range in ViewModel"
  81. },
  82. # let/run/apply/also blocks
  83. {
  84. pattern: /\.(let|run|apply|also|with)\s*\{/,
  85. message: "scope function - move logic to ViewModel"
  86. }
  87. ].freeze
  88. # Allowed simple patterns that look like logic but are acceptable
  89. 1 ALLOWED_PATTERNS = [
  90. # Simple property access (including safe call)
  91. /^@\{[a-zA-Z_][a-zA-Z0-9_]*(\??\.[a-zA-Z_][a-zA-Z0-9_]*)*\}$/,
  92. # Simple negation for boolean
  93. /^@\{![a-zA-Z_][a-zA-Z0-9_]*\}$/,
  94. # Simple array access with constant index
  95. /^@\{[a-zA-Z_][a-zA-Z0-9_]*\[\d+\]\}$/,
  96. # Action bindings (callbacks)
  97. /^@\{on[A-Z][a-zA-Z0-9_]*\}$/,
  98. # data. prefix for accessing data properties (e.g., @{data.name} in Collection cells)
  99. /^@\{data\.[a-zA-Z_][a-zA-Z0-9_.]*\}$/
  100. ].freeze
  101. 1 def initialize
  102. 38 @warnings = []
  103. 38 @data_properties = Set.new
  104. end
  105. # Validate all bindings in a JSON component tree
  106. # @param json_data [Hash] The root component
  107. # @param file_name [String] The file name for error messages
  108. # @return [Array<String>] Array of warning messages
  109. 1 def validate(json_data, file_name = nil)
  110. 35 @warnings = []
  111. 35 @current_file = file_name
  112. 35 @data_properties = Set.new
  113. # First pass: collect all data property names
  114. 35 collect_data_properties(json_data)
  115. # Second pass: validate bindings
  116. 35 validate_component(json_data)
  117. 35 @warnings
  118. end
  119. # Check if there are any warnings
  120. 1 def has_warnings?
  121. 2 !@warnings.empty?
  122. end
  123. # Print all warnings to stdout
  124. 1 def print_warnings
  125. @warnings.each do |warning|
  126. puts "\e[33m[KJUI Binding Warning]\e[0m #{warning}"
  127. end
  128. end
  129. # Check a single binding expression
  130. # @param binding_expr [String] The binding expression (without @{ })
  131. # @param attribute_name [String] The attribute name
  132. # @param component_type [String] The component type
  133. # @return [Array<String>] Array of warning messages
  134. 1 def check_binding(binding_expr, attribute_name, component_type)
  135. 35 warnings = []
  136. # Check if it's allowed simple pattern
  137. 35 full_binding = "@{#{binding_expr}}"
  138. 35 return warnings if allowed_pattern?(full_binding)
  139. # Check for business logic patterns
  140. 14 BUSINESS_LOGIC_PATTERNS.each do |rule|
  141. 210 if binding_expr.match?(rule[:pattern])
  142. 16 context = @current_file ? "[#{@current_file}] " : ""
  143. 16 warnings << "#{context}Binding '@{#{binding_expr}}' in '#{component_type}.#{attribute_name}' contains #{rule[:message]}"
  144. end
  145. end
  146. 14 warnings
  147. end
  148. 1 private
  149. # Collect all data property names from the component tree
  150. 1 def collect_data_properties(component)
  151. 48 return unless component.is_a?(Hash)
  152. # Check for data declarations
  153. 48 if component['data'].is_a?(Array)
  154. 22 component['data'].each do |data_item|
  155. 29 next unless data_item.is_a?(Hash)
  156. # Skip ViewModel class declarations (they have 'class' key but no 'name')
  157. # e.g., { "class": "MyViewModel" } - this is a ViewModel class, not a property
  158. # But include property declarations: { "name": "userName", "class": "String" }
  159. 29 next if data_item['class'] && !data_item['name']
  160. # Add property name to the set
  161. 28 if data_item['name']
  162. 28 @data_properties << data_item['name']
  163. end
  164. end
  165. end
  166. # Recurse into children
  167. 48 children = component['child'] || component['children'] || []
  168. 48 children = [children] unless children.is_a?(Array)
  169. 58 children.each { |child| collect_data_properties(child) if child.is_a?(Hash) }
  170. # Recurse into sections
  171. 48 if component['sections'].is_a?(Array)
  172. 2 component['sections'].each do |section|
  173. 2 next unless section.is_a?(Hash)
  174. 2 ['header', 'footer', 'cell'].each do |key|
  175. 6 collect_data_properties(section[key]) if section[key].is_a?(Hash)
  176. end
  177. end
  178. end
  179. end
  180. 1 def validate_component(component, parent_type = nil)
  181. 48 return unless component.is_a?(Hash)
  182. 48 component_type = component['type'] || parent_type || 'Unknown'
  183. # Check each attribute for bindings
  184. 48 component.each do |key, value|
  185. 122 next if key == 'type' || key == 'child' || key == 'children' || key == 'sections'
  186. 61 next if key == 'data' || key == 'generatedBy' || key == 'include' || key == 'style'
  187. 37 check_value_for_bindings(value, key, component_type)
  188. end
  189. # Validate children
  190. 48 children = component['child'] || component['children'] || []
  191. 48 children = [children] unless children.is_a?(Array)
  192. 58 children.each { |child| validate_component(child, component_type) if child.is_a?(Hash) }
  193. # Validate sections (Collection/Table)
  194. 48 if component['sections'].is_a?(Array)
  195. 2 component['sections'].each do |section|
  196. 2 next unless section.is_a?(Hash)
  197. 2 ['header', 'footer', 'cell'].each do |key|
  198. 6 validate_component(section[key], component_type) if section[key].is_a?(Hash)
  199. end
  200. end
  201. end
  202. end
  203. 1 def check_value_for_bindings(value, attribute_name, component_type)
  204. # Check visibility attribute for Boolean type (should use String enum: visible, gone, invisible)
  205. # Must be called for all value types including TrueClass/FalseClass
  206. 37 check_visibility_type(value, attribute_name, component_type)
  207. 37 case value
  208. when String
  209. 37 if value.start_with?('@{') && value.end_with?('}')
  210. 33 binding_expr = value[2..-2] # Remove @{ and }
  211. 33 binding_warnings = check_binding(binding_expr, attribute_name, component_type)
  212. 33 @warnings.concat(binding_warnings)
  213. # Check if binding variables are defined in data
  214. 33 check_undefined_variables(binding_expr, attribute_name, component_type)
  215. end
  216. when Hash
  217. value.each do |k, v|
  218. check_value_for_bindings(v, "#{attribute_name}.#{k}", component_type)
  219. end
  220. when Array
  221. value.each_with_index do |item, index|
  222. check_value_for_bindings(item, "#{attribute_name}[#{index}]", component_type)
  223. end
  224. end
  225. end
  226. # Check if variables in binding expression are defined in data
  227. 1 def check_undefined_variables(binding_expr, attribute_name, component_type)
  228. # Skip data. prefix bindings (Collection cell bindings)
  229. 33 return if binding_expr.start_with?('data.')
  230. # Extract variable names from the binding expression
  231. 31 variables = extract_variables(binding_expr)
  232. 31 variables.each do |var|
  233. 34 unless @data_properties.include?(var)
  234. 8 context = @current_file ? "[#{@current_file}] " : ""
  235. 8 @warnings << "#{context}Binding variable '#{var}' in '#{component_type}.#{attribute_name}' is not defined in data. Add: { \"class\": \"#{infer_type(var, attribute_name)}\", \"name\": \"#{var}\" }"
  236. end
  237. end
  238. end
  239. # Extract variable names from binding expression
  240. 1 def extract_variables(binding_expr)
  241. 31 variables = Set.new
  242. # Remove string literals to avoid false positives
  243. 31 expr = binding_expr.gsub(/'[^']*'/, '').gsub(/"[^"]*"/, '')
  244. # Match variable names (identifiers that are not keywords or literals)
  245. # Skip: numbers, true, false, null, visible, gone
  246. 31 keywords = %w[true false null visible gone]
  247. 31 expr.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/).flatten.each do |match|
  248. 34 next if keywords.include?(match)
  249. 34 next if match =~ /^\d/ # Skip if starts with digit
  250. 34 variables << match
  251. end
  252. 31 variables.to_a
  253. end
  254. # Infer type from variable name and attribute context
  255. # Returns Kotlin type format
  256. 1 def infer_type(var_name, attribute_name)
  257. # onClick, onXxx -> (() -> Unit)? (Kotlin callback type)
  258. 8 return '(() -> Unit)?' if var_name.start_with?('on') && var_name[2]&.match?(/[A-Z]/)
  259. # xxxItems, xxxOptions, xxxList -> List<Any>
  260. 7 return 'List<Any>' if var_name.end_with?('Items', 'Options', 'List', 'Args', 'Subcommands')
  261. # isXxx, hasXxx, canXxx, shouldXxx -> Boolean
  262. 6 return 'Boolean' if var_name.start_with?('is', 'has', 'can', 'should')
  263. # xxxVisibility -> String
  264. 5 return 'String' if var_name.end_with?('Visibility')
  265. # xxxIndex, xxxCount, xxxTab -> Int
  266. 5 return 'Int' if var_name.end_with?('Index', 'Count', 'Tab')
  267. # xxxMargin, xxxPadding -> Dp (Kotlin Compose)
  268. 4 return 'Dp' if var_name.end_with?('Margin', 'Padding')
  269. # Based on attribute name
  270. 4 case attribute_name
  271. when 'onClick', 'onValueChanged', 'onValueChange', 'onTap'
  272. '(() -> Unit)?'
  273. when 'items'
  274. 'CollectionDataSource'
  275. when 'sections'
  276. 'List<Any>'
  277. when 'visibility', 'text', 'fontColor', 'background'
  278. 4 'String'
  279. when 'selectedIndex', 'width', 'height'
  280. 'Int'
  281. when 'hidden', 'enabled', 'disabled'
  282. 'Boolean'
  283. when 'topMargin', 'bottomMargin', 'leftMargin', 'rightMargin', 'startMargin', 'endMargin'
  284. 'Dp'
  285. when 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingStart', 'paddingEnd'
  286. 'Dp'
  287. else
  288. 'Any'
  289. end
  290. end
  291. 1 def allowed_pattern?(binding)
  292. 129 ALLOWED_PATTERNS.any? { |pattern| binding.match?(pattern) }
  293. end
  294. # Check if visibility attribute is using Boolean instead of String enum
  295. # Valid values: "visible", "gone", "invisible"
  296. # Invalid: true, false, @{booleanProperty}
  297. 1 def check_visibility_type(value, attribute_name, component_type)
  298. 37 return unless attribute_name == 'visibility'
  299. # Check for literal boolean values
  300. 2 if value == true || value == false || value == 'true' || value == 'false'
  301. context = @current_file ? "[#{@current_file}] " : ""
  302. @warnings << "#{context}'#{component_type}.visibility' should use String enum (\"visible\", \"gone\", \"invisible\"), not Boolean. Use a String property in data section with visibility values."
  303. return
  304. end
  305. # Check for binding to boolean property (isXxx, hasXxx, etc.)
  306. 2 if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  307. 2 binding_expr = value[2..-2]
  308. # Check if binding name suggests boolean (isXxx, hasXxx, canXxx, shouldXxx)
  309. 2 if binding_expr.match?(/^(is|has|can|should)[A-Z]/)
  310. context = @current_file ? "[#{@current_file}] " : ""
  311. @warnings << "#{context}'#{component_type}.visibility' binding '@{#{binding_expr}}' appears to be Boolean. Use String property with values: \"visible\", \"gone\", or \"invisible\"."
  312. end
  313. end
  314. end
  315. end
  316. end
  317. end

lib/core/config_manager.rb

98.86% lines covered

88 relevant lines. 87 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'pathname'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ConfigManager
  7. 1 CONFIG_FILE = 'kjui.config.json'
  8. DEFAULT_CONFIG = {
  9. 1 'mode' => 'compose',
  10. 'project_name' => '',
  11. 'package_name' => 'com.example.app',
  12. 'source_directory' => 'app/src/main',
  13. 'layouts_directory' => 'assets/Layouts',
  14. 'styles_directory' => 'assets/Styles',
  15. 'view_directory' => 'kotlin/com/example/app/views',
  16. 'data_directory' => 'kotlin/com/example/app/data',
  17. 'viewmodel_directory' => 'kotlin/com/example/app/viewmodels',
  18. 'extension_directory' => 'library/src/main/kotlin/com/kotlinjsonui/extensions',
  19. 'adapter_directory' => 'library/src/main/kotlin/com/kotlinjsonui/adapters',
  20. 'custom_view_types' => {},
  21. 'compose' => {
  22. 'output_directory' => 'kotlin/com/example/app/generated'
  23. },
  24. 'xml' => {
  25. 'bindings_directory' => 'java/com/example/app/bindings'
  26. }
  27. }.freeze
  28. 1 class << self
  29. 1 def load_config
  30. 133 config_path = find_config_file
  31. 133 base_config = if config_path && File.exist?(config_path)
  32. begin
  33. 26 config_data = JSON.parse(File.read(config_path))
  34. # Store the config directory for use by generators
  35. 25 config_data['_config_dir'] = File.dirname(config_path)
  36. 25 config_data
  37. rescue JSON::ParserError => e
  38. 1 puts "Error parsing config file: #{e.message}"
  39. 1 {}
  40. end
  41. else
  42. 107 {}
  43. end
  44. # Merge with default config to ensure all keys exist
  45. 133 deep_merge(DEFAULT_CONFIG, base_config)
  46. end
  47. # Find config file in project
  48. 1 def find_config_file
  49. # First check current directory
  50. 136 return CONFIG_FILE if File.exist?(CONFIG_FILE)
  51. # Check subdirectories for kjui.config.json
  52. 109 Dir.glob(File.join(Dir.pwd, '**/kjui.config.json')).each do |config_path|
  53. # Skip hidden directories and node_modules
  54. 1 next if config_path.include?('/.') || config_path.include?('/node_modules/')
  55. 1 return config_path
  56. end
  57. # Check parent directories up to 3 levels
  58. 108 current = Dir.pwd
  59. 108 3.times do
  60. 324 current = File.dirname(current)
  61. 324 config_path = File.join(current, CONFIG_FILE)
  62. 324 return config_path if File.exist?(config_path)
  63. end
  64. nil
  65. end
  66. 1 def save_config(config)
  67. 3 File.write(CONFIG_FILE, JSON.pretty_generate(config))
  68. end
  69. # Deep merge two hashes
  70. 1 def deep_merge(hash1, hash2)
  71. 150 hash1.merge(hash2) do |key, old_val, new_val|
  72. 142 if old_val.is_a?(Hash) && new_val.is_a?(Hash)
  73. 15 deep_merge(old_val, new_val)
  74. else
  75. 127 new_val
  76. end
  77. end
  78. end
  79. 1 def config_exists?
  80. 2 File.exist?(CONFIG_FILE)
  81. end
  82. 1 def get(key, default = nil)
  83. 21 config = load_config
  84. 21 keys = key.split('.')
  85. 21 value = config
  86. 21 keys.each do |k|
  87. 22 value = value[k] if value.is_a?(Hash)
  88. end
  89. 21 value || default
  90. end
  91. 1 def set(key, value)
  92. 2 config = load_config
  93. 2 keys = key.split('.')
  94. 2 current = config
  95. 2 keys[0...-1].each do |k|
  96. 1 current[k] ||= {}
  97. 1 current = current[k]
  98. end
  99. 2 current[keys.last] = value
  100. 2 save_config(config)
  101. end
  102. 1 def detect_mode
  103. # Check for Android project files
  104. 7 gradle_files = Dir.glob('build.gradle*')
  105. 7 settings_gradle = Dir.glob('settings.gradle*')
  106. 7 if gradle_files.any? || settings_gradle.any?
  107. # Check if it's a Compose project
  108. 2 build_file = gradle_files.first
  109. 2 if build_file && File.exist?(build_file)
  110. 2 content = File.read(build_file)
  111. 2 if content.include?('compose') || content.include?('androidx.compose')
  112. 1 return 'compose'
  113. end
  114. end
  115. # Default to XML for Android projects
  116. 1 return 'xml'
  117. end
  118. # Default mode
  119. 5 'all'
  120. end
  121. 1 def project_type
  122. 4 mode = get('mode', detect_mode)
  123. 4 case mode
  124. when 'compose'
  125. 1 'Jetpack Compose'
  126. when 'xml'
  127. 1 'Android XML'
  128. when 'all'
  129. 1 'Android (XML + Compose)'
  130. else
  131. 1 'Unknown'
  132. end
  133. end
  134. 1 def source_path
  135. 7 get('source_directory', 'app/src/main')
  136. end
  137. 1 def layouts_path
  138. 1 Pathname.new(source_path).join(get('layouts_directory', 'assets/Layouts'))
  139. end
  140. 1 def styles_path
  141. 1 Pathname.new(source_path).join(get('styles_directory', 'assets/Styles'))
  142. end
  143. 1 def view_path
  144. 1 Pathname.new(source_path).join(get('view_directory', 'java/com/example/app/ui'))
  145. end
  146. 1 def data_path
  147. 1 Pathname.new(source_path).join(get('data_directory', 'java/com/example/app/data'))
  148. end
  149. 1 def viewmodel_path
  150. 1 Pathname.new(source_path).join(get('viewmodel_directory', 'java/com/example/app/viewmodel'))
  151. end
  152. 1 def generated_path
  153. 1 if get('mode') == 'compose'
  154. 1 compose_config = get('compose', {})
  155. 1 Pathname.new(source_path).join(compose_config['output_directory'] || 'java/com/example/app/generated')
  156. else
  157. Pathname.new(source_path).join(get('bindings_directory', 'java/com/example/app/bindings'))
  158. end
  159. end
  160. end
  161. end
  162. end
  163. end

lib/core/json_loader.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class JsonLoader
  4. 1 def initialize(config)
  5. 32 @config = config
  6. end
  7. 1 def load_layout(layout_name)
  8. 10 layout_file = find_layout_file(layout_name)
  9. 10 if layout_file && File.exist?(layout_file)
  10. 8 File.read(layout_file)
  11. else
  12. nil
  13. end
  14. end
  15. 1 def load_json(file_path)
  16. 2 if File.exist?(file_path)
  17. 1 File.read(file_path)
  18. else
  19. nil
  20. end
  21. end
  22. 1 private
  23. 1 def find_layout_file(layout_name)
  24. # Remove .json extension if present
  25. 10 layout_name = layout_name.sub(/\.json$/, '')
  26. 10 project_path = @config['project_path'] || Dir.pwd
  27. # Check multiple possible locations
  28. possible_paths = [
  29. 10 File.join(project_path, 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  30. File.join(project_path, 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  31. File.join(project_path, 'Layouts', "#{layout_name}.json"),
  32. File.join(project_path, "#{layout_name}.json")
  33. ]
  34. 32 possible_paths.find { |path| File.exist?(path) }
  35. end
  36. end

lib/core/logger.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Core
  4. 1 class Logger
  5. 1 class << self
  6. 1 def info(message)
  7. 82 puts " #{message}"
  8. end
  9. 1 def success(message)
  10. 4 puts "✅ #{message}"
  11. end
  12. 1 def error(message)
  13. 2 puts "❌ #{message}"
  14. end
  15. 1 def warn(message)
  16. 16 puts "⚠️ #{message}"
  17. end
  18. 1 def debug(message)
  19. 73 puts "🔍 #{message}" if ENV['DEBUG']
  20. end
  21. 1 def newline
  22. 1 puts
  23. end
  24. end
  25. end
  26. end
  27. end

lib/core/project_finder.rb

93.22% lines covered

59 relevant lines. 55 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'pathname'
  3. 1 require 'find'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ProjectFinder
  7. 1 class << self
  8. 1 attr_accessor :project_dir, :project_file_path
  9. 1 def setup_paths
  10. 1 @project_dir = find_project_dir
  11. 1 @project_file_path = find_project_file
  12. end
  13. 1 def find_project_dir
  14. # Look for Android project indicators
  15. 4 current_dir = Dir.pwd
  16. # Check current directory
  17. 4 return current_dir if android_project?(current_dir)
  18. # Check parent directory
  19. 3 parent_dir = File.dirname(current_dir)
  20. 3 return parent_dir if android_project?(parent_dir)
  21. # Default to current directory
  22. 2 current_dir
  23. end
  24. 1 def find_project_file
  25. # Look for main build.gradle or build.gradle.kts
  26. 4 gradle_files = Dir.glob('build.gradle*')
  27. 4 return gradle_files.first if gradle_files.any?
  28. # Check parent directory
  29. 2 parent_gradle = Dir.glob('../build.gradle*')
  30. 2 return File.expand_path(parent_gradle.first) if parent_gradle.any?
  31. nil
  32. end
  33. 1 def find_source_directory
  34. # Common Android source directory patterns
  35. 3 common_paths = [
  36. 'app/src/main',
  37. 'src/main',
  38. 'src',
  39. 'app'
  40. ]
  41. 3 project_root = @project_dir || Dir.pwd
  42. 3 common_paths.each do |path|
  43. 7 full_path = File.join(project_root, path)
  44. 7 return path if Dir.exist?(full_path)
  45. end
  46. # Try to find any src directory
  47. 1 Find.find(project_root) do |path|
  48. 1 if File.directory?(path) && File.basename(path) == 'src'
  49. # Check if it contains main directory
  50. main_path = File.join(path, 'main')
  51. if Dir.exist?(main_path)
  52. return Pathname.new(main_path).relative_path_from(Pathname.new(project_root)).to_s
  53. end
  54. return Pathname.new(path).relative_path_from(Pathname.new(project_root)).to_s
  55. end
  56. end
  57. # Default
  58. 1 'app/src/main'
  59. end
  60. 1 def get_full_source_path
  61. 54 @project_dir || Dir.pwd
  62. end
  63. 1 def get_package_name
  64. 1 package_name
  65. end
  66. 1 def package_name
  67. # Try to detect package name from AndroidManifest.xml
  68. 4 manifest_paths = [
  69. 'app/src/main/AndroidManifest.xml',
  70. 'src/main/AndroidManifest.xml',
  71. 'AndroidManifest.xml'
  72. ]
  73. 4 project_root = @project_dir || Dir.pwd
  74. 4 manifest_paths.each do |path|
  75. 10 full_path = File.join(project_root, path)
  76. 10 if File.exist?(full_path)
  77. 1 content = File.read(full_path)
  78. # Extract package name from manifest
  79. 1 if content =~ /package="([^"]+)"/
  80. 1 return $1
  81. end
  82. end
  83. end
  84. # Try to detect from build.gradle
  85. 3 gradle_files = Dir.glob('**/build.gradle*')
  86. 3 gradle_files.each do |gradle_file|
  87. 2 content = File.read(gradle_file)
  88. # Look for namespace first (more reliable)
  89. 2 if content =~ /namespace\s*=\s*["']([^"']+)["']/
  90. 1 return $1
  91. end
  92. # Look for applicationId
  93. 1 if content =~ /applicationId\s*=\s*["']([^"']+)["']/
  94. 1 return $1
  95. end
  96. end
  97. # Default package name
  98. 1 'com.example.app'
  99. end
  100. 1 private
  101. 1 def android_project?(dir)
  102. # Check for Android project indicators
  103. 7 indicators = [
  104. 'build.gradle',
  105. 'build.gradle.kts',
  106. 'settings.gradle',
  107. 'settings.gradle.kts',
  108. 'gradlew',
  109. 'app/build.gradle',
  110. 'app/build.gradle.kts'
  111. ]
  112. 46 indicators.any? { |indicator| File.exist?(File.join(dir, indicator)) }
  113. end
  114. end
  115. end
  116. end
  117. end

lib/core/resources/color_manager.rb

87.32% lines covered

355 relevant lines. 310 lines covered and 45 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require 'rexml/formatters/pretty'
  6. 1 require_relative '../logger'
  7. 1 module KjuiTools
  8. 1 module Core
  9. 1 module Resources
  10. 1 class ColorManager
  11. 1 def initialize(config, source_path, resources_dir)
  12. 78 @config = config
  13. 78 @source_path = source_path
  14. 78 @resources_dir = resources_dir
  15. 78 @colors_file = File.join(@resources_dir, 'colors.json')
  16. 78 @defined_colors_file = File.join(@resources_dir, 'defined_colors.json')
  17. 78 @extracted_colors = {}
  18. 78 @undefined_colors = {}
  19. 78 @colors_data = load_colors_json
  20. 78 @defined_colors_data = load_defined_colors_json
  21. end
  22. # Main process method called from ResourcesManager
  23. 1 def process_colors(processed_files, processed_count, skipped_count, config)
  24. 11 return if processed_files.empty?
  25. 10 Core::Logger.info "Extracting colors from #{processed_count} files (#{skipped_count} skipped)..."
  26. # Extract colors from JSON files
  27. 10 extract_colors(processed_files)
  28. # Save updated colors.json if there are new colors
  29. 10 save_colors_json if @extracted_colors.any?
  30. # Save undefined colors to defined_colors.json
  31. 10 save_defined_colors_json if @undefined_colors.any?
  32. # Generate ColorManager.kt if needed
  33. 10 generate_color_manager_kotlin if @config['resource_manager_directory']
  34. end
  35. # Apply extracted colors to color resources
  36. 1 def apply_to_color_assets
  37. # Save any pending colors to colors.json
  38. 11 save_colors_json if @extracted_colors.any?
  39. # Save undefined colors to defined_colors.json
  40. 11 save_defined_colors_json if @undefined_colors.any?
  41. # Apply colors to Android colors.xml
  42. 11 apply_to_colors_xml
  43. end
  44. 1 private
  45. # Load existing colors.json file
  46. 1 def load_colors_json
  47. 89 return {} unless File.exist?(@colors_file)
  48. begin
  49. 10 JSON.parse(File.read(@colors_file))
  50. 1 rescue JSON::ParserError => e
  51. 1 Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  52. 1 {}
  53. end
  54. end
  55. # Load existing defined_colors.json file
  56. 1 def load_defined_colors_json
  57. 89 return {} unless File.exist?(@defined_colors_file)
  58. begin
  59. 6 JSON.parse(File.read(@defined_colors_file))
  60. 1 rescue JSON::ParserError => e
  61. 1 Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  62. 1 {}
  63. end
  64. end
  65. # Save colors data to colors.json
  66. 1 def save_colors_json
  67. # Merge extracted colors with existing colors
  68. 2 @colors_data.merge!(@extracted_colors)
  69. # Ensure Resources directory exists
  70. 2 FileUtils.mkdir_p(@resources_dir)
  71. # Write colors.json
  72. 2 File.write(@colors_file, JSON.pretty_generate(@colors_data))
  73. 2 Core::Logger.info "Updated colors.json with #{@extracted_colors.size} new colors"
  74. # Clear extracted colors after saving
  75. 2 @extracted_colors.clear
  76. end
  77. # Apply colors to Android colors.xml file
  78. 1 def apply_to_colors_xml
  79. 11 colors_xml_path = File.join(@source_path, @config['source_directory'] || 'src/main', 'res/values/colors.xml')
  80. 11 unless File.exist?(colors_xml_path)
  81. 8 Core::Logger.info "colors.xml not found at: #{colors_xml_path}, creating new file"
  82. # Ensure the directory exists
  83. 8 colors_dir = File.dirname(colors_xml_path)
  84. 8 FileUtils.mkdir_p(colors_dir)
  85. # Create a new colors.xml with basic structure
  86. 8 default_xml = <<~XML
  87. <?xml version="1.0" encoding="utf-8"?>
  88. <resources>
  89. </resources>
  90. XML
  91. 8 File.write(colors_xml_path, default_xml)
  92. end
  93. # Load colors from colors.json
  94. 11 all_colors = load_colors_json
  95. # Also include defined colors (only those with actual values that don't already exist)
  96. 11 defined_colors = load_defined_colors_json
  97. 11 defined_colors.each do |key, value|
  98. # Only add if value exists and all_colors doesn't already have this key
  99. 3 all_colors[key] = value if value && !all_colors.key?(key)
  100. end
  101. 11 return if all_colors.empty?
  102. # Read and parse existing colors.xml
  103. 4 xml_content = File.read(colors_xml_path)
  104. 4 doc = REXML::Document.new(xml_content)
  105. 4 resources = doc.root
  106. 4 unless resources
  107. Core::Logger.error "Invalid colors.xml structure"
  108. return
  109. end
  110. # Build a hash of existing colors for faster lookup
  111. 4 existing_colors = {}
  112. 4 resources.elements.each('color') do |elem|
  113. 1 name = elem.attributes['name']
  114. 1 existing_colors[name] = elem if name
  115. end
  116. # Add or update colors
  117. 4 colors_added = 0
  118. 4 colors_updated = 0
  119. 4 all_colors.each do |key, value|
  120. # Skip if value is nil or not a hex color
  121. 6 next unless value && value.is_a?(String) && value.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  122. # Normalize hex color (ensure it has # and is uppercase)
  123. 6 hex_value = value.start_with?('#') ? value.upcase : "##{value.upcase}"
  124. # Convert 6-digit hex to 8-digit ARGB format if needed (Android requires ARGB)
  125. 6 if hex_value.length == 7 # #RRGGBB
  126. 6 hex_value = "#FF#{hex_value[1..-1]}" # Add full opacity
  127. end
  128. 6 if existing_colors[key]
  129. # Update existing color if value is different
  130. current_value = existing_colors[key].text
  131. if current_value != hex_value
  132. existing_colors[key].text = hex_value
  133. colors_updated += 1
  134. end
  135. else
  136. # Add new color element
  137. 6 color_elem = REXML::Element.new('color')
  138. 6 color_elem.add_attribute('name', key)
  139. 6 color_elem.text = hex_value
  140. 6 resources.add_element(color_elem)
  141. 6 colors_added += 1
  142. end
  143. end
  144. 4 if colors_added > 0 || colors_updated > 0
  145. # Format and write back
  146. 4 formatter = REXML::Formatters::Pretty.new(2)
  147. 4 formatter.compact = true
  148. 4 output = String.new
  149. 4 formatter.write(doc, output)
  150. 4 File.write(colors_xml_path, output)
  151. 4 Core::Logger.info "Updated colors.xml: #{colors_added} added, #{colors_updated} updated"
  152. end
  153. end
  154. # Save undefined colors to defined_colors.json
  155. 1 def save_defined_colors_json
  156. # Merge new undefined colors with existing defined colors
  157. @defined_colors_data.merge!(@undefined_colors)
  158. # Ensure Resources directory exists
  159. FileUtils.mkdir_p(@resources_dir)
  160. # Write defined_colors.json
  161. File.write(@defined_colors_file, JSON.pretty_generate(@defined_colors_data))
  162. Core::Logger.info "Updated defined_colors.json with #{@undefined_colors.size} undefined color keys"
  163. # Clear undefined colors after saving
  164. @undefined_colors.clear
  165. end
  166. # Extract color values from processed JSON files
  167. 1 def extract_colors(processed_files)
  168. 10 @modified_files = []
  169. 10 Core::Logger.debug "Processing #{processed_files.size} files for colors"
  170. 10 processed_files.each do |json_file|
  171. begin
  172. 10 Core::Logger.debug "Processing file: #{json_file}"
  173. 10 content = File.read(json_file)
  174. 10 data = JSON.parse(content)
  175. # Extract and replace colors recursively from JSON structure
  176. 10 modified = replace_colors_recursive(data)
  177. 10 Core::Logger.debug "File modified: #{modified}, extracted colors: #{@extracted_colors.size}"
  178. # Save modified JSON file if any colors were replaced
  179. 10 if modified
  180. 2 File.write(json_file, JSON.pretty_generate(data))
  181. 2 @modified_files << json_file
  182. 2 Core::Logger.debug "Updated colors in: #{json_file}"
  183. end
  184. rescue JSON::ParserError => e
  185. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  186. rescue => e
  187. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  188. end
  189. end
  190. 10 if @modified_files.any?
  191. 2 Core::Logger.info "Replaced colors in #{@modified_files.size} files"
  192. end
  193. end
  194. # Replace colors recursively in JSON data
  195. 1 def replace_colors_recursive(data, parent_key = nil)
  196. 26 modified = false
  197. 26 case data
  198. when Hash
  199. 20 data.each do |key, value|
  200. # Check if this key is a color property and value is a string
  201. 32 if is_color_property?(key) && value.is_a?(String)
  202. # Skip binding expressions (starting with @{)
  203. 7 if value.start_with?('@{') && value.end_with?('}')
  204. 2 Core::Logger.debug "Skipping binding expression: #{value}"
  205. 2 next
  206. end
  207. # Process and replace the color value (hex or string key)
  208. 5 new_value = process_and_replace_color(value)
  209. 5 if new_value != value
  210. 5 data[key] = new_value
  211. 5 modified = true
  212. 5 Core::Logger.debug "Replaced #{value} with #{new_value} in #{key}"
  213. end
  214. 25 elsif value.is_a?(Hash) || value.is_a?(Array)
  215. # Recurse into nested structures
  216. 6 child_modified = replace_colors_recursive(value, key)
  217. 6 modified ||= child_modified
  218. end
  219. end
  220. when Array
  221. 6 data.each_with_index do |item, index|
  222. 5 if item.is_a?(Hash) || item.is_a?(Array)
  223. 5 child_modified = replace_colors_recursive(item, parent_key)
  224. 5 modified ||= child_modified
  225. end
  226. end
  227. end
  228. 26 modified
  229. end
  230. # Check if a property name is likely to contain a color
  231. 1 def is_color_property?(key)
  232. # Based on actual XML style_mapper.rb and Compose components
  233. 47 color_properties = [
  234. # Common background/appearance (style_mapper.rb)
  235. 'background',
  236. 'backgroundColor',
  237. 'borderColor',
  238. 'strokeColor',
  239. # Text colors (text_mapper.rb)
  240. 'fontColor',
  241. 'textColor',
  242. 'color', # Generic color that can map to textColor or tint
  243. # State-specific backgrounds (drawable generation)
  244. 'disabledBackground',
  245. 'tapBackground',
  246. 'pressedBackground',
  247. 'selectedBackground',
  248. 'focusedBackground',
  249. 'checkedBackground',
  250. 'rippleColor',
  251. # Input/SelectBox specific (input_mapper.rb, SelectBox component)
  252. 'hintColor',
  253. 'cancelButtonBackgroundColor',
  254. 'cancelButtonTextColor',
  255. # Image/Icon tinting
  256. 'tint',
  257. 'tintColor',
  258. # Gradient colors (style_mapper.rb)
  259. 'gradientStartColor',
  260. 'startColor',
  261. 'gradientEndColor',
  262. 'endColor',
  263. 'gradientCenterColor',
  264. 'centerColor',
  265. # Blur overlay
  266. 'blurOverlayColor',
  267. # Shadow
  268. 'shadowColor'
  269. ]
  270. 47 color_properties.include?(key.to_s)
  271. end
  272. # Process and replace a color value, returning the color key
  273. 1 def process_and_replace_color(color_value)
  274. # Skip data binding expressions
  275. 10 return color_value if color_value.is_a?(String) && color_value.start_with?('@{')
  276. # Handle hex colors
  277. 8 if is_hex_color?(color_value)
  278. # Check if it's a fully transparent color (alpha = 00)
  279. 7 if is_transparent_color?(color_value)
  280. # Add transparent to colors.json if not already present
  281. unless @colors_data.key?('transparent') || @extracted_colors.key?('transparent')
  282. @extracted_colors['transparent'] = '#00000000'
  283. end
  284. return 'transparent'
  285. end
  286. # Normalize hex color (uppercase, with #)
  287. 7 hex_color = normalize_hex_color(color_value)
  288. # Check if color already exists in colors.json
  289. 7 existing_key = find_color_key(hex_color)
  290. 7 if existing_key
  291. # Color already exists, return the key
  292. 1 Core::Logger.debug "Found existing color: #{existing_key} = #{hex_color}"
  293. 1 return existing_key
  294. else
  295. # Generate a new key for this color
  296. 6 new_key = generate_color_key(hex_color)
  297. # Add to extracted colors
  298. 6 @extracted_colors[new_key] = hex_color
  299. 6 Core::Logger.debug "New color found: #{new_key} = #{hex_color}"
  300. 6 return new_key
  301. end
  302. # Handle string color keys
  303. 1 elsif color_value.is_a?(String) && !color_value.empty?
  304. # Check if this color key exists in colors.json
  305. 1 if @colors_data.key?(color_value) || @extracted_colors.key?(color_value)
  306. # Color key exists, keep it as is
  307. Core::Logger.debug "Color key exists: #{color_value}"
  308. return color_value
  309. 1 elsif @defined_colors_data.key?(color_value)
  310. # Already in defined_colors, keep it as is
  311. Core::Logger.debug "Color key already in defined_colors: #{color_value}"
  312. return color_value
  313. else
  314. # Undefined color key, add to undefined colors list
  315. 1 @undefined_colors[color_value] = nil
  316. 1 Core::Logger.debug "Undefined color key found: #{color_value}"
  317. 1 return color_value
  318. end
  319. else
  320. # Return as is for other types
  321. return color_value
  322. end
  323. end
  324. # Find existing key for a hex color
  325. 1 def find_color_key(hex_color)
  326. # Check both existing colors and newly extracted colors
  327. 7 all_colors = @colors_data.merge(@extracted_colors)
  328. 9 all_colors.find { |key, value| value.upcase == hex_color.upcase }&.first
  329. end
  330. # Generate a descriptive key name based on RGB values
  331. 1 def generate_color_key(hex_color)
  332. # Parse RGB values from hex
  333. 12 rgb = parse_hex_to_rgb(hex_color)
  334. 12 return 'unknown_color' unless rgb
  335. 12 r, g, b = rgb
  336. # Calculate brightness and dominant color
  337. 12 brightness = (r + g + b) / 3.0
  338. # Determine base name from brightness
  339. 12 base_name = if brightness > 230
  340. 1 'white'
  341. 11 elsif brightness > 200
  342. 'pale'
  343. 11 elsif brightness > 150
  344. 'light'
  345. 11 elsif brightness > 100
  346. 1 'medium'
  347. 10 elsif brightness > 50
  348. 7 'dark'
  349. 3 elsif brightness > 20
  350. 'deep'
  351. else
  352. 3 'black'
  353. end
  354. # Find dominant color if not grayscale
  355. 12 max_diff = [r, g, b].max - [r, g, b].min
  356. 12 if max_diff > 30 # Not grayscale
  357. # Determine dominant color
  358. 7 if r > g && r > b
  359. 7 if r - g > 50 && r - b > 50
  360. 7 color_suffix = '_red'
  361. elsif r > b
  362. color_suffix = '_orange' if g > b
  363. color_suffix = '_pink' if b > g * 0.7
  364. else
  365. color_suffix = '_magenta'
  366. end
  367. elsif g > r && g > b
  368. if g - r > 50 && g - b > 50
  369. color_suffix = '_green'
  370. elsif g > b && r > b * 0.7
  371. color_suffix = '_yellow'
  372. else
  373. color_suffix = '_lime'
  374. end
  375. elsif b > r && b > g
  376. if b - r > 50 && b - g > 50
  377. color_suffix = '_blue'
  378. elsif b > r && g > r * 0.7
  379. color_suffix = '_cyan'
  380. else
  381. color_suffix = '_purple'
  382. end
  383. else
  384. color_suffix = ''
  385. end
  386. 7 base_name = base_name + color_suffix unless base_name == 'white' || base_name == 'black'
  387. 5 elsif base_name != 'white' && base_name != 'black'
  388. 1 base_name = base_name + '_gray'
  389. end
  390. # Handle duplicates by adding suffix
  391. 12 final_key = base_name
  392. 12 counter = 2
  393. 12 all_colors = @colors_data.merge(@extracted_colors)
  394. 12 while all_colors.key?(final_key)
  395. 1 final_key = "#{base_name}_#{counter}"
  396. 1 counter += 1
  397. end
  398. 12 final_key
  399. end
  400. # Parse hex color to RGB values (and alpha if present)
  401. 1 def parse_hex_to_rgb(hex_color)
  402. # Remove # if present
  403. 18 hex = hex_color.gsub('#', '')
  404. # Support both 3 and 6 digit hex
  405. 18 if hex.length == 3
  406. 4 hex = hex.chars.map { |c| c * 2 }.join
  407. end
  408. # Handle 8-digit hex (ARGB) - extract RGB part
  409. 18 if hex.length == 8
  410. # Skip alpha channel (first 2 characters) for RGB analysis
  411. 1 hex = hex[2..7]
  412. end
  413. 18 return nil unless hex.length == 6
  414. [
  415. 17 hex[0..1].to_i(16),
  416. hex[2..3].to_i(16),
  417. hex[4..5].to_i(16)
  418. ]
  419. rescue
  420. nil
  421. end
  422. # Check if a value is a hex color
  423. 1 def is_hex_color?(value)
  424. 18 return false unless value.is_a?(String)
  425. # Support 3, 6, and 8 character hex colors (8 = ARGB with alpha)
  426. 16 value.match?(/^#?[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/)
  427. end
  428. # Check if a color is fully transparent (alpha = 00)
  429. 1 def is_transparent_color?(value)
  430. 7 return false unless value.is_a?(String)
  431. 7 hex = value.gsub('#', '').upcase
  432. # Only 8-digit hex colors have alpha channel (ARGB format for Android)
  433. 7 return false unless hex.length == 8
  434. # Check if alpha is 00 (fully transparent) - first 2 chars for ARGB
  435. alpha = hex[0..1]
  436. alpha == '00'
  437. end
  438. # Normalize hex color format
  439. 1 def normalize_hex_color(hex_color)
  440. 11 hex = hex_color.gsub('#', '').upcase
  441. # Convert 3-digit to 6-digit
  442. 11 if hex.length == 3
  443. 4 hex = hex.chars.map { |c| c * 2 }.join
  444. end
  445. # Keep 8-digit (ARGB) as is
  446. # 6-digit and 8-digit are both valid
  447. 11 "##{hex}"
  448. end
  449. # Generate Kotlin code for ColorManager
  450. 1 def generate_color_manager_kotlin
  451. 1 return unless @config['resource_manager_directory']
  452. 1 resource_manager_dir = File.join(@source_path, @config['resource_manager_directory'])
  453. 1 FileUtils.mkdir_p(resource_manager_dir)
  454. 1 output_file = File.join(resource_manager_dir, 'ColorManager.kt')
  455. # Combine all colors (from colors.json and defined_colors.json)
  456. 1 all_colors = @colors_data.dup
  457. # Add defined colors (keys without values yet)
  458. 1 @defined_colors_data.each do |key, _|
  459. all_colors[key] ||= nil
  460. end
  461. 1 kotlin_code = generate_kotlin_code(all_colors)
  462. 1 File.write(output_file, kotlin_code)
  463. 1 Core::Logger.info "✓ Generated ColorManager.kt"
  464. end
  465. 1 def generate_kotlin_code(colors)
  466. 12 timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  467. 12 code = []
  468. 12 code << "// ColorManager.kt"
  469. 12 code << "// Auto-generated file - DO NOT EDIT"
  470. 12 code << "// Generated at: #{timestamp}"
  471. 12 code << ""
  472. 12 code << "package com.kotlinjsonui.generated"
  473. 12 code << ""
  474. 12 code << "import android.graphics.Color"
  475. 12 code << "import android.util.Log"
  476. 12 code << "import androidx.compose.ui.graphics.Color as ComposeColor"
  477. 12 code << ""
  478. 12 code << "object ColorManager {"
  479. 12 code << " private const val TAG = \"ColorManager\""
  480. 12 code << " "
  481. 12 code << " // Load colors from colors.json"
  482. 12 if @colors_data.empty?
  483. 11 code << " private val colorsData: Map<String, String> = emptyMap()"
  484. else
  485. 1 code << " private val colorsData: Map<String, String> = mapOf("
  486. # Add defined colors from colors.json
  487. 1 @colors_data.each_with_index do |(key, hex_value), index|
  488. 2 comma = index < @colors_data.size - 1 ? "," : ""
  489. 2 code << " \"#{key}\" to \"#{hex_value}\"#{comma}"
  490. end
  491. 1 code << " )"
  492. end
  493. 12 code << ""
  494. 12 code << " // Android Views colors"
  495. 12 code << " object views {"
  496. 12 code << " // Get Color by key (returns null for binding expressions like @{...})"
  497. 12 code << " fun color(key: String): Int? {"
  498. 12 code << " // Skip binding expressions"
  499. 12 code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
  500. 12 code << " return null"
  501. 12 code << " }"
  502. 12 code << " val hexString = colorsData[key]"
  503. 12 code << " if (hexString == null) {"
  504. 12 code << " Log.w(TAG, \"Color key '$key' not found in colors.json\")"
  505. 12 code << " return try {"
  506. 12 code << " Color.parseColor(key) // Try to parse key as hex color"
  507. 12 code << " } catch (e: IllegalArgumentException) {"
  508. 12 code << " null"
  509. 12 code << " }"
  510. 12 code << " }"
  511. 12 code << " return try {"
  512. 12 code << " Color.parseColor(hexString)"
  513. 12 code << " } catch (e: IllegalArgumentException) {"
  514. 12 code << " Log.w(TAG, \"Invalid color format '$hexString' for key '$key'\")"
  515. 12 code << " null"
  516. 12 code << " }"
  517. 12 code << " }"
  518. 12 code << ""
  519. # Generate static color accessors for Android Views
  520. 12 colors.keys.sort.each do |key|
  521. 12 property_name = snake_to_camel(key)
  522. 12 code << " val #{property_name}: Int?"
  523. 12 code << " get() {"
  524. 12 if @colors_data[key]
  525. 2 code << " return try {"
  526. 2 code << " Color.parseColor(\"#{@colors_data[key]}\")"
  527. 2 code << " } catch (e: IllegalArgumentException) {"
  528. 2 code << " Log.w(TAG, \"Invalid color format '#{@colors_data[key]}' for '#{key}'\")"
  529. 2 code << " null"
  530. 2 code << " }"
  531. else
  532. 10 code << " // Undefined color - needs to be defined in colors.json"
  533. 10 code << " Log.w(TAG, \"Color '#{key}' is not defined in colors.json\")"
  534. 10 code << " return null"
  535. end
  536. 12 code << " }"
  537. 12 code << ""
  538. end
  539. 12 code << " }"
  540. 12 code << ""
  541. 12 code << " // Jetpack Compose colors"
  542. 12 code << " object compose {"
  543. 12 code << " // Get Compose Color by key (returns null for binding expressions like @{...})"
  544. 12 code << " fun color(key: String): ComposeColor? {"
  545. 12 code << " // Skip binding expressions"
  546. 12 code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
  547. 12 code << " return null"
  548. 12 code << " }"
  549. 12 code << " val androidColor = views.color(key) ?: return null"
  550. 12 code << " return ComposeColor(androidColor)"
  551. 12 code << " }"
  552. 12 code << ""
  553. # Generate static Compose Color accessors
  554. 12 colors.keys.sort.each do |key|
  555. 12 property_name = snake_to_camel(key)
  556. 12 code << " val #{property_name}: ComposeColor?"
  557. 12 code << " get() {"
  558. 12 code << " val androidColor = views.#{property_name} ?: return null"
  559. 12 code << " return ComposeColor(androidColor)"
  560. 12 code << " }"
  561. 12 code << ""
  562. end
  563. 12 code << " }"
  564. 12 code << "}"
  565. 12 code << ""
  566. 12 code << "// Note: Color parsing extensions are provided by KotlinJsonUI library"
  567. 12 code.join("\n")
  568. end
  569. 1 def snake_to_camel(snake_case)
  570. # Convert snake_case to camelCase
  571. # Examples:
  572. # primary_blue -> primaryBlue
  573. # white_2 -> white2
  574. # dark_gray -> darkGray
  575. 28 parts = snake_case.split('_')
  576. 28 first_part = parts.shift
  577. 28 camel = first_part + parts.map(&:capitalize).join
  578. 28 camel
  579. end
  580. end
  581. end
  582. end
  583. end

lib/core/resources/string_manager.rb

61.6% lines covered

237 relevant lines. 146 lines covered and 91 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require_relative '../logger'
  6. 1 module KjuiTools
  7. 1 module Core
  8. 1 module Resources
  9. 1 class StringManager
  10. 1 def initialize(config, source_path, resources_dir)
  11. 48 @config = config
  12. 48 @source_path = source_path
  13. 48 @resources_dir = resources_dir
  14. 48 @strings_file = File.join(@resources_dir, 'strings.json')
  15. 48 @extracted_strings = {} # Structure: { "filename": { "key": "value" } }
  16. 48 @strings_data = load_strings_json
  17. end
  18. # Main process method called from ResourcesManager
  19. 1 def process_strings(processed_files, processed_count, skipped_count)
  20. 9 return if processed_files.empty?
  21. 8 Core::Logger.info "Extracting strings from #{processed_count} files (#{skipped_count} skipped)..."
  22. # Extract strings from JSON files
  23. 8 extract_strings(processed_files)
  24. # Save updated strings.json if there are new strings
  25. 8 save_strings_json if @extracted_strings.any?
  26. # Generate StringManager.kt if needed
  27. # Disabled: StringManager.kt generation is not needed
  28. # generate_string_manager_kotlin if @config['resource_manager_directory']
  29. end
  30. # Apply extracted strings to strings.xml files
  31. 1 def apply_to_strings_files
  32. 9 return if @strings_data.empty?
  33. # Get string files from config
  34. 5 string_files = @config['string_files'] || []
  35. 5 if string_files.empty?
  36. # Default: update strings.xml for default language
  37. 5 update_strings_xml('values')
  38. else
  39. # Update configured string files
  40. string_files.each do |string_file_path|
  41. # Extract values directory from path (e.g., "res/values-ja/strings.xml" -> "values-ja")
  42. if string_file_path =~ /res\/(values[^\/]*)\//
  43. lang_dir = $1
  44. update_strings_xml(lang_dir)
  45. elsif string_file_path =~ /(values[^\/]*)\//
  46. lang_dir = $1
  47. update_strings_xml(lang_dir)
  48. else
  49. # If no standard pattern, try to use the parent directory name
  50. parts = string_file_path.split('/')
  51. if parts.length >= 2
  52. lang_dir = parts[-2]
  53. update_strings_xml(lang_dir) if lang_dir.start_with?('values')
  54. end
  55. end
  56. end
  57. end
  58. end
  59. 1 private
  60. # Load existing strings.json file
  61. 1 def load_strings_json
  62. 48 return {} unless File.exist?(@strings_file)
  63. begin
  64. JSON.parse(File.read(@strings_file))
  65. rescue JSON::ParserError => e
  66. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  67. {}
  68. end
  69. end
  70. # Save strings data to strings.json
  71. 1 def save_strings_json
  72. # Count total new strings
  73. 5 total_new_strings = 0
  74. 5 @extracted_strings.each do |file_prefix, file_strings|
  75. 5 total_new_strings += file_strings.size
  76. end
  77. # Merge extracted strings with existing strings
  78. 5 @extracted_strings.each do |file_prefix, file_strings|
  79. 5 @strings_data[file_prefix] ||= {}
  80. 5 @strings_data[file_prefix].merge!(file_strings)
  81. end
  82. # Ensure Resources directory exists
  83. 5 FileUtils.mkdir_p(@resources_dir)
  84. # Write strings.json
  85. 5 File.write(@strings_file, JSON.pretty_generate(@strings_data))
  86. 5 Core::Logger.info "Updated strings.json with #{total_new_strings} new strings"
  87. # Clear extracted strings after saving
  88. 5 @extracted_strings.clear
  89. end
  90. # Extract string values from processed JSON files
  91. 1 def extract_strings(processed_files)
  92. 8 @modified_files = []
  93. 8 Core::Logger.debug "Processing #{processed_files.size} files for strings"
  94. # Get the layouts directory to calculate relative paths
  95. 8 layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  96. 8 processed_files.each do |json_file|
  97. begin
  98. 8 Core::Logger.debug "Processing file: #{json_file}"
  99. 8 content = File.read(json_file)
  100. 8 data = JSON.parse(content)
  101. # Get file prefix from relative path
  102. 8 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  103. 8 file_prefix = generate_file_prefix(relative_path)
  104. # Create current file strings container if not exists
  105. 8 @current_file_strings = {}
  106. # Extract strings recursively from JSON structure (without modifying)
  107. 8 extract_strings_recursive(data, nil, file_prefix)
  108. # Store extracted strings for this file if any
  109. 8 if @current_file_strings.any?
  110. 5 @extracted_strings[file_prefix] ||= {}
  111. 5 @extracted_strings[file_prefix].merge!(@current_file_strings)
  112. 5 Core::Logger.debug "Extracted #{@current_file_strings.size} strings from #{file_prefix}"
  113. end
  114. # NOTE: We don't modify the original JSON files anymore
  115. # The resource resolution happens during code generation
  116. rescue JSON::ParserError => e
  117. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  118. rescue => e
  119. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  120. end
  121. end
  122. 8 if @modified_files.any?
  123. Core::Logger.info "Replaced strings in #{@modified_files.size} files"
  124. end
  125. end
  126. # Generate file prefix from relative path
  127. 1 def generate_file_prefix(relative_path)
  128. # Remove .json extension and replace / with _
  129. # Examples:
  130. # "test.json" -> "test"
  131. # "subdir/test.json" -> "subdir_test"
  132. # "a/b/c/test.json" -> "a_b_c_test"
  133. 11 relative_path
  134. .gsub(/\.json$/, '')
  135. .gsub('/', '_')
  136. end
  137. # Extract strings recursively from JSON data (without modifying)
  138. 1 def extract_strings_recursive(data, parent_key = nil, file_prefix = nil)
  139. 17 case data
  140. when Hash
  141. 12 data.each do |key, value|
  142. # Special handling for partialAttributes
  143. 25 if key == 'partialAttributes' && value.is_a?(Array)
  144. value.each do |partial_attr|
  145. if partial_attr.is_a?(Hash) && partial_attr['range'].is_a?(String)
  146. # Process range text when it's a string (not an array)
  147. range_text = partial_attr['range']
  148. if !range_text.empty? && should_extract_string?(range_text)
  149. extract_and_store_string(range_text, file_prefix)
  150. end
  151. end
  152. end
  153. # Regular string property handling
  154. 25 elsif is_string_property?(key) && value.is_a?(String) && !value.empty?
  155. # Extract the string value
  156. 5 if should_extract_string?(value)
  157. 5 extract_and_store_string(value, file_prefix)
  158. end
  159. 20 elsif value.is_a?(Hash) || value.is_a?(Array)
  160. # Recurse into nested structures
  161. 5 extract_strings_recursive(value, key, file_prefix)
  162. end
  163. end
  164. when Array
  165. 5 data.each_with_index do |item, index|
  166. 4 if item.is_a?(Hash) || item.is_a?(Array)
  167. 4 extract_strings_recursive(item, parent_key, file_prefix)
  168. end
  169. end
  170. end
  171. end
  172. # Check if a property name is likely to contain a localizable string
  173. 1 def is_string_property?(key)
  174. # Based on actual XML mapper and Compose components code
  175. 32 string_properties = [
  176. 'text', # Text, Button, TextField, TextView, Checkbox
  177. 'hint', # TextField, SelectBox (both XML and Compose)
  178. 'placeholder', # TextField, SelectBox alternative to hint
  179. 'label', # Checkbox label
  180. 'prompt' # SelectBox (maps to placeholder in XML)
  181. ]
  182. 32 string_properties.include?(key.to_s)
  183. end
  184. # Check if a string should be extracted for localization
  185. 1 def should_extract_string?(value)
  186. # Skip data binding expressions
  187. 12 return false if value.start_with?('@{') || value.start_with?('${')
  188. # Skip if it's already a snake_case key (already converted)
  189. # This prevents re-extracting strings that have been replaced with keys
  190. 10 return false if value.match?(/^[a-z]+(_[a-z0-9]+)*$/)
  191. # Extract if it's a regular text string longer than 2 characters
  192. # and contains alphabetic characters
  193. 8 value.length > 2 && value.match?(/[a-zA-Z]/)
  194. end
  195. # Extract and store string (without returning a key)
  196. 1 def extract_and_store_string(value, file_prefix = nil)
  197. # Generate a snake_case key from the text
  198. 5 key = generate_string_key(value)
  199. # Check if this exact string already has a key in this file
  200. 5 existing_key = find_string_key_in_file(value, file_prefix)
  201. 5 if existing_key
  202. Core::Logger.debug "String already extracted: #{existing_key}"
  203. return
  204. end
  205. # Add to current file strings
  206. 5 @current_file_strings[key] = value
  207. 5 Core::Logger.debug "New string extracted: #{key} = '#{value}'"
  208. end
  209. # Find existing key for a string value in a specific file
  210. 1 def find_string_key_in_file(value, file_prefix)
  211. 5 return nil unless file_prefix
  212. # Check if this file has been processed before
  213. 5 if @strings_data[file_prefix]
  214. # Look for existing key in this file's strings
  215. @strings_data[file_prefix].find { |key, val| val == value }&.first
  216. end
  217. # Also check current file's strings being extracted
  218. 5 if @current_file_strings
  219. 5 found_key = @current_file_strings.find { |key, val| val == value }&.first
  220. 5 return "#{file_prefix}_#{found_key}" if found_key
  221. end
  222. nil
  223. end
  224. # Find existing key for a string value (legacy method)
  225. 1 def find_string_key(value)
  226. # Check both existing strings and newly extracted strings
  227. all_strings = @strings_data.merge(@extracted_strings)
  228. all_strings.find { |key, val| val == value }&.first
  229. end
  230. # Generate a snake_case key from text
  231. 1 def generate_string_key(text)
  232. # Convert to snake_case
  233. 10 base_key = text
  234. .downcase
  235. .gsub(/[^a-z0-9\s]/, '') # Remove special characters
  236. .gsub(/\s+/, '_') # Replace spaces with underscores
  237. .gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
  238. .gsub(/__+/, '_') # Replace multiple underscores with single
  239. # Limit length
  240. 10 base_key = base_key[0..30] if base_key.length > 30
  241. # Handle duplicates
  242. 10 final_key = base_key
  243. 10 counter = 2
  244. 10 all_strings = @strings_data.merge(@extracted_strings)
  245. 10 while all_strings.key?(final_key)
  246. final_key = "#{base_key}_#{counter}"
  247. counter += 1
  248. end
  249. 10 final_key
  250. end
  251. # Update strings.xml file for a specific language
  252. 1 def update_strings_xml(lang_dir)
  253. 4 Core::Logger.debug "Updating strings.xml for #{lang_dir}..."
  254. 4 res_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'res', lang_dir)
  255. 4 FileUtils.mkdir_p(res_dir)
  256. 4 strings_xml_file = File.join(res_dir, 'strings.xml')
  257. 4 Core::Logger.debug "Strings.xml path: #{strings_xml_file}"
  258. # Load existing strings.xml or create new
  259. 4 doc = if File.exist?(strings_xml_file)
  260. Core::Logger.debug "Loading existing strings.xml..."
  261. REXML::Document.new(File.read(strings_xml_file))
  262. else
  263. 4 Core::Logger.debug "Creating new strings.xml..."
  264. 4 create_new_strings_xml
  265. end
  266. 4 resources = doc.root
  267. 4 Core::Logger.debug "Processing #{@strings_data.keys.length} files..."
  268. # Build a hash of existing strings for faster lookup
  269. 4 existing_strings = {}
  270. 4 resources.elements.each('string') do |elem|
  271. name = elem.attributes['name']
  272. existing_strings[name] = elem if name
  273. end
  274. 4 Core::Logger.debug "Found #{existing_strings.keys.length} existing strings"
  275. # Add new strings from strings.json (now structured by file)
  276. 4 @strings_data.each do |file_prefix, file_strings|
  277. 4 next unless file_strings.is_a?(Hash)
  278. 4 Core::Logger.debug "Processing #{file_prefix} with #{file_strings.keys.length} strings..."
  279. 4 file_strings.each do |key, value|
  280. # Create full key with file prefix
  281. 4 full_key = "#{file_prefix}_#{key}"
  282. # Check if string already exists (using hash lookup - much faster)
  283. 4 unless existing_strings[full_key]
  284. # Add new string element
  285. 4 string_elem = REXML::Element.new('string')
  286. 4 string_elem.add_attribute('name', full_key)
  287. # Use translated value if available for this language
  288. 4 translated_value = get_translated_value(full_key, value, lang_dir)
  289. # Trim whitespace and normalize the string for XML
  290. 4 normalized_value = translated_value.strip.gsub(/\s+/, ' ')
  291. # Escape apostrophes for Android XML strings
  292. 4 normalized_value = normalized_value.gsub("'", "\\'")
  293. # Don't let REXML auto-escape, we'll do it manually
  294. 4 string_elem.text = normalized_value
  295. 4 resources.add_element(string_elem)
  296. 4 Core::Logger.debug "Added string '#{full_key}' to #{lang_dir}/strings.xml"
  297. end
  298. end
  299. end
  300. # Write updated XML with custom formatting to prevent multiline strings
  301. 4 File.open(strings_xml_file, 'w') do |file|
  302. # Use a custom formatter that doesn't wrap text content
  303. 4 formatter = REXML::Formatters::Pretty.new(4)
  304. 4 formatter.compact = true # Don't add extra whitespace inside text
  305. 4 formatter.write(doc, file)
  306. end
  307. 4 Core::Logger.info "Updated #{lang_dir}/strings.xml"
  308. end
  309. # Create a new strings.xml document
  310. 1 def create_new_strings_xml
  311. 6 doc = REXML::Document.new
  312. 6 doc.add(REXML::XMLDecl.new('1.0', 'utf-8'))
  313. 6 resources = REXML::Element.new('resources')
  314. 6 doc.add_element(resources)
  315. 6 doc
  316. end
  317. # Get translated value for a specific language
  318. 1 def get_translated_value(key, default_value, lang_dir)
  319. # For now, return the default value
  320. # In the future, this could load translations from a separate file
  321. 5 default_value
  322. end
  323. # Generate Kotlin code for StringManager
  324. 1 def generate_string_manager_kotlin
  325. return unless @config['resource_manager_directory']
  326. resource_manager_dir = File.join(@source_path, @config['source_directory'] || 'src/main',
  327. 'java/com/kotlinjsonui/generated')
  328. FileUtils.mkdir_p(resource_manager_dir)
  329. output_file = File.join(resource_manager_dir, 'StringManager.kt')
  330. kotlin_code = generate_kotlin_code(@strings_data)
  331. File.write(output_file, kotlin_code)
  332. Core::Logger.info "✓ Generated StringManager.kt"
  333. end
  334. 1 def generate_kotlin_code(strings)
  335. timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  336. code = []
  337. code << "// StringManager.kt"
  338. code << "// Auto-generated file - DO NOT EDIT"
  339. code << "// Generated at: #{timestamp}"
  340. code << ""
  341. code << "package com.kotlinjsonui.generated"
  342. code << ""
  343. code << "import android.content.Context"
  344. code << ""
  345. code << "object StringManager {"
  346. code << " // String resource IDs mapped from strings.json keys"
  347. code << " private val stringResources: Map<String, Int> = mapOf("
  348. # Add string resource mappings
  349. strings.keys.sort.each do |key|
  350. code << " \"#{key}\" to R.string.#{key},"
  351. end
  352. # Remove trailing comma from last item
  353. if strings.any?
  354. code[-1] = code[-1].chomp(',')
  355. end
  356. code << " )"
  357. code << ""
  358. code << " // Get localized string by key"
  359. code << " fun getString(context: Context, key: String): String {"
  360. code << " val resId = stringResources[key]"
  361. code << " return if (resId != null) {"
  362. code << " context.getString(resId)"
  363. code << " } else {"
  364. code << " // Fallback to key itself if not found"
  365. code << " println(\"Warning: String key '$key' not found in strings.json\")"
  366. code << " key"
  367. code << " }"
  368. code << " }"
  369. code << ""
  370. code << " // Extension function for easy access"
  371. code << " fun String.localized(context: Context): String {"
  372. code << " // Check if this is a string key (snake_case)"
  373. code << " return if (this.matches(Regex(\"^[a-z]+(_[a-z]+)*$\"))) {"
  374. code << " getString(context, this)"
  375. code << " } else {"
  376. code << " // Return as-is if not a key"
  377. code << " this"
  378. code << " }"
  379. code << " }"
  380. code << ""
  381. # Generate static accessors for each string
  382. strings.keys.sort.each do |key|
  383. property_name = snake_to_camel(key)
  384. code << " // Access string: #{key}"
  385. code << " fun get#{property_name.capitalize}(context: Context): String ="
  386. code << " getString(context, \"#{key}\")"
  387. code << ""
  388. end
  389. code << "}"
  390. code.join("\n")
  391. end
  392. 1 def snake_to_camel(snake_case)
  393. 3 parts = snake_case.split('_')
  394. 3 first_part = parts.shift
  395. 3 camel = first_part + parts.map(&:capitalize).join
  396. 3 camel
  397. end
  398. end
  399. end
  400. end
  401. end

lib/core/resources_manager.rb

100.0% lines covered

45 relevant lines. 45 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative 'config_manager'
  5. 1 require_relative 'project_finder'
  6. 1 require_relative 'logger'
  7. 1 require_relative 'resources/string_manager'
  8. 1 require_relative 'resources/color_manager'
  9. 1 module KjuiTools
  10. 1 module Core
  11. 1 class ResourcesManager
  12. 1 def initialize(config, source_path)
  13. 17 @config = config
  14. 17 @source_path = source_path
  15. 17 @layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  16. 17 @resources_dir = File.join(@layouts_dir, 'Resources')
  17. 17 @string_manager = Resources::StringManager.new(@config, @source_path, @resources_dir)
  18. 17 @color_manager = Resources::ColorManager.new(@config, @source_path, @resources_dir)
  19. end
  20. # Main method called from build command
  21. 1 def extract_resources(json_files)
  22. # Extract resources from JSON files
  23. 10 extract_from_json_files(json_files)
  24. # Apply extracted strings to strings.xml files
  25. 10 apply_extracted_strings
  26. # Apply extracted colors
  27. 10 apply_extracted_colors
  28. end
  29. # Extract resources from JSON files
  30. 1 def extract_from_json_files(json_files)
  31. 13 processed_files = []
  32. 13 processed_count = 0
  33. 13 skipped_count = 0
  34. 13 json_files.each do |json_file|
  35. # Skip files in Resources directory only
  36. 11 if json_file.include?('/Resources/')
  37. 2 skipped_count += 1
  38. 2 next
  39. end
  40. 9 processed_files << json_file
  41. 9 processed_count += 1
  42. end
  43. 13 if processed_count == 0
  44. 4 Logger.info "No files to process for resource extraction"
  45. 4 return
  46. end
  47. 9 Logger.info "Extracting resources from #{processed_count} files (#{skipped_count} skipped)..."
  48. # Ensure Resources directory exists
  49. 9 FileUtils.mkdir_p(@resources_dir)
  50. # Process strings through StringManager
  51. 9 @string_manager.process_strings(processed_files, processed_count, skipped_count)
  52. # Process colors through ColorManager
  53. 9 @color_manager.process_colors(processed_files, processed_count, skipped_count, @config)
  54. end
  55. 1 private
  56. 1 def apply_extracted_strings
  57. 10 Logger.info "Applying extracted strings to strings.xml files..."
  58. 10 @string_manager.apply_to_strings_files
  59. end
  60. 1 def apply_extracted_colors
  61. 10 Logger.info "Applying extracted colors..."
  62. 10 @color_manager.apply_to_color_assets
  63. end
  64. end
  65. end
  66. end

lib/core/style_loader.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class StyleLoader
  4. 1 def initialize(config)
  5. 39 @config = config
  6. 39 @styles = {}
  7. 39 load_styles
  8. end
  9. 1 def apply_styles(json_data)
  10. 13 apply_styles_recursive(json_data)
  11. 13 json_data
  12. end
  13. 1 private
  14. 1 def load_styles
  15. 39 project_path = @config['project_path'] || Dir.pwd
  16. 39 styles_dir = File.join(project_path, 'src', 'main', 'assets', 'Styles')
  17. 39 styles_dir = File.join(project_path, 'app', 'src', 'main', 'assets', 'Styles') unless Dir.exist?(styles_dir)
  18. 39 return unless Dir.exist?(styles_dir)
  19. 14 Dir.glob(File.join(styles_dir, '*.json')).each do |style_file|
  20. 25 style_name = File.basename(style_file, '.json')
  21. begin
  22. 25 style_content = File.read(style_file)
  23. 25 @styles[style_name] = JSON.parse(style_content)
  24. rescue => e
  25. 1 puts "Warning: Failed to load style #{style_name}: #{e.message}"
  26. end
  27. end
  28. end
  29. 1 def apply_styles_recursive(element)
  30. 18 return unless element.is_a?(Hash)
  31. # Apply style if present
  32. 17 if element['style']
  33. 8 style_names = element['style'].is_a?(Array) ? element['style'] : [element['style']]
  34. 8 style_names.each do |style_name|
  35. 9 if @styles[style_name]
  36. # Merge style attributes (style attributes are overridden by inline attributes)
  37. 8 @styles[style_name].each do |key, value|
  38. 15 element[key] = value unless element.key?(key)
  39. end
  40. end
  41. end
  42. # Remove style attribute after applying
  43. 8 element.delete('style')
  44. end
  45. # Apply recursively to children
  46. 17 if element['children']
  47. 2 element['children'].each { |child| apply_styles_recursive(child) }
  48. 16 elsif element['child']
  49. 5 if element['child'].is_a?(Array)
  50. 7 element['child'].each { |child| apply_styles_recursive(child) }
  51. else
  52. 1 apply_styles_recursive(element['child'])
  53. end
  54. end
  55. end
  56. end

lib/core/type_converter.rb

73.47% lines covered

98 relevant lines. 72 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Core
  4. # Converts JSON primitive types to Kotlin types
  5. # This ensures cross-platform compatibility with SwiftJsonUI and ReactJsonUI
  6. 1 class TypeConverter
  7. # Language key for this platform
  8. 1 LANGUAGE = 'kotlin'
  9. # Available modes for this platform
  10. 1 MODES = %w[compose xml].freeze
  11. # JSON type -> Kotlin type mapping (common types)
  12. 1 TYPE_MAPPING = {
  13. # Standard types (cross-platform)
  14. 'String' => 'String',
  15. 'string' => 'String',
  16. 'Int' => 'Int',
  17. 'int' => 'Int',
  18. 'Integer' => 'Int',
  19. 'integer' => 'Int',
  20. 'Double' => 'Double',
  21. 'double' => 'Double',
  22. 'Float' => 'Float',
  23. 'float' => 'Float',
  24. 'Bool' => 'Boolean',
  25. 'bool' => 'Boolean',
  26. 'Boolean' => 'Boolean',
  27. 'boolean' => 'Boolean',
  28. # iOS-specific types mapped to Kotlin equivalents
  29. 'CGFloat' => 'Float',
  30. 'Void' => 'Unit',
  31. 'void' => 'Unit',
  32. # Kotlin/Compose-specific types
  33. 'Dp' => 'Dp',
  34. 'Alignment' => 'Alignment',
  35. # Collection types
  36. 'CollectionDataSource' => 'CollectionDataSource'
  37. }.freeze
  38. # Mode-specific type mapping (types that differ between compose and xml)
  39. MODE_TYPE_MAPPING = {
  40. 1 'Color' => { 'compose' => 'Color', 'xml' => 'Int' },
  41. 'color' => { 'compose' => 'Color', 'xml' => 'Int' },
  42. 'Image' => { 'compose' => 'Painter', 'xml' => 'Drawable' },
  43. 'image' => { 'compose' => 'Painter', 'xml' => 'Drawable' }
  44. }.freeze
  45. # Default values for each Kotlin type
  46. 1 DEFAULT_VALUES = {
  47. 'String' => '""',
  48. 'Int' => '0',
  49. 'Double' => '0.0',
  50. 'Float' => '0f',
  51. 'Boolean' => 'false',
  52. 'Color' => 'Color.Unspecified',
  53. 'Dp' => '0.dp',
  54. 'Alignment' => 'Alignment.TopStart',
  55. 'Painter' => 'EmptyPainter()',
  56. 'Drawable' => 'null',
  57. 'CollectionDataSource' => 'CollectionDataSource()'
  58. }.freeze
  59. 1 class << self
  60. # Extract platform-specific value from a potentially nested hash
  61. # Supports three formats:
  62. # 1. Simple value: "String" -> "String"
  63. # 2. Language only: { "swift": "Int", "kotlin": "Int" } -> "Int"
  64. # 3. Language + mode: { "kotlin": { "compose": "Color", "xml": "Int" } } -> "Color" or "Int"
  65. #
  66. # @param value [Object] the value (String, Hash, or other)
  67. # @param mode [String] the mode (compose, xml)
  68. # @return [Object] the extracted value for this platform/mode
  69. 1 def extract_platform_value(value, mode = nil)
  70. 33 return value unless value.is_a?(Hash)
  71. # Try to get language-specific value
  72. 9 lang_value = value[LANGUAGE]
  73. 9 return value unless lang_value # No language key found, return original hash
  74. # If language value is a hash, try to get mode-specific value
  75. 8 if lang_value.is_a?(Hash) && mode
  76. 7 mode_value = lang_value[mode]
  77. 7 return mode_value if mode_value
  78. # Fallback: try first available mode
  79. 1 MODES.each do |m|
  80. 1 return lang_value[m] if lang_value[m]
  81. end
  82. # No mode found, return the hash as-is (might be a custom structure)
  83. lang_value
  84. else
  85. # Language value is not a hash, return it directly
  86. 1 lang_value
  87. end
  88. end
  89. # Convert JSON type to Kotlin type
  90. # @param json_type [String] the type specified in JSON
  91. # @param mode [String] the mode (compose, xml) for mode-specific types
  92. # @return [String] the corresponding Kotlin type
  93. 1 def to_kotlin_type(json_type, mode = nil)
  94. 77 return json_type if json_type.nil? || json_type.to_s.empty?
  95. 75 type_str = json_type.to_s
  96. # Check for Array(ElementType) syntax -> List<ElementType>
  97. 75 if (match = type_str.match(/^Array\((.+)\)$/))
  98. 4 element_type = to_kotlin_type(match[1].strip, mode)
  99. 4 return "List<#{element_type}>"
  100. end
  101. # Check for Dictionary(KeyType,ValueType) syntax -> Map<KeyType, ValueType>
  102. 71 if (match = type_str.match(/^Dictionary\((.+),\s*(.+)\)$/))
  103. 3 key_type = to_kotlin_type(match[1].strip, mode)
  104. 3 value_type = to_kotlin_type(match[2].strip, mode)
  105. 3 return "Map<#{key_type}, #{value_type}>"
  106. end
  107. # Check for Swift optional callback syntax (() -> Void)? -> (() -> Unit)?
  108. 68 if (match = type_str.match(/^\(\((.*)?\)\s*->\s*Void\)\?$/))
  109. 2 params = match[1]&.strip
  110. 2 if params.nil? || params.empty?
  111. 1 return "(() -> Unit)?"
  112. else
  113. # Convert parameter types
  114. 2 param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
  115. 1 return "((#{param_types}) -> Unit)?"
  116. end
  117. end
  118. # Check for Swift callback syntax (() -> Void) -> () -> Unit (with outer parens)
  119. 66 if (match = type_str.match(/^\(\((.*)?\)\s*->\s*Void\)$/))
  120. 5 params = match[1]&.strip
  121. 5 if params.nil? || params.empty?
  122. 1 return "() -> Unit"
  123. else
  124. # Convert parameter types
  125. 9 param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
  126. 4 return "(#{param_types}) -> Unit"
  127. end
  128. end
  129. # Check for Swift simple callback syntax () -> Void -> (() -> Unit)? (without outer parens, treated as optional)
  130. 61 if (match = type_str.match(/^\((.*)?\)\s*->\s*Void$/))
  131. 2 params = match[1]&.strip
  132. 2 if params.nil? || params.empty?
  133. 1 return "(() -> Unit)?"
  134. else
  135. # Convert parameter types
  136. 2 param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
  137. 1 return "((#{param_types}) -> Unit)?"
  138. end
  139. end
  140. # Check mode-specific mapping first
  141. 59 if mode && MODE_TYPE_MAPPING.key?(type_str)
  142. 8 return MODE_TYPE_MAPPING[type_str][mode] || MODE_TYPE_MAPPING[type_str]['compose']
  143. end
  144. # Then check common mapping, or return as-is if not found
  145. 51 TYPE_MAPPING[type_str] || type_str
  146. end
  147. # Check if the type is a primitive type
  148. # @param json_type [String] the type to check
  149. # @return [Boolean] true if it's a primitive type
  150. 1 def primitive?(json_type)
  151. 9 return false if json_type.nil? || json_type.to_s.empty?
  152. 7 TYPE_MAPPING.key?(json_type.to_s)
  153. end
  154. # Get default value for a Kotlin type
  155. # @param kotlin_type [String] the Kotlin type
  156. # @return [String] the default value as Kotlin code
  157. 1 def default_value(kotlin_type)
  158. 9 DEFAULT_VALUES[kotlin_type] || 'null'
  159. end
  160. # Format a value for Kotlin code based on type
  161. # @param value [Object] the value to format
  162. # @param kotlin_type [String] the Kotlin type
  163. # @return [String] the formatted value as Kotlin code
  164. 1 def format_value(value, kotlin_type)
  165. return 'null' if value.nil?
  166. case kotlin_type
  167. when 'String'
  168. format_string_value(value)
  169. when 'Int'
  170. value.to_i.to_s
  171. when 'Double'
  172. "#{value.to_f}"
  173. when 'Float'
  174. "#{value.to_f}f"
  175. when 'Boolean'
  176. value.to_s.downcase
  177. when 'Color'
  178. format_color_value(value)
  179. else
  180. value.to_s
  181. end
  182. end
  183. # Convert data property from JSON format to normalized format
  184. # @param data_prop [Hash] the data property from JSON
  185. # @param mode [String] the mode (compose, xml)
  186. # @return [Hash] normalized data property with Kotlin type
  187. 1 def normalize_data_property(data_prop, mode = nil)
  188. 15 return data_prop unless data_prop.is_a?(Hash)
  189. 15 normalized = data_prop.dup
  190. # Extract platform-specific class
  191. 15 if normalized['class']
  192. 15 raw_class = extract_platform_value(normalized['class'], mode)
  193. 15 normalized['class'] = to_kotlin_type(raw_class, mode)
  194. end
  195. # Extract platform-specific defaultValue
  196. 15 if normalized['defaultValue']
  197. 11 normalized['defaultValue'] = extract_platform_value(normalized['defaultValue'], mode)
  198. end
  199. 15 normalized
  200. end
  201. # Convert array of data properties
  202. # @param data_props [Array<Hash>] array of data properties
  203. # @param mode [String] the mode (compose, xml)
  204. # @return [Array<Hash>] normalized data properties
  205. 1 def normalize_data_properties(data_props, mode = nil)
  206. 3 return [] unless data_props.is_a?(Array)
  207. 4 data_props.map { |prop| normalize_data_property(prop, mode) }
  208. end
  209. 1 private
  210. 1 def format_string_value(value)
  211. str = value.to_s
  212. # Handle already quoted strings
  213. if str.start_with?('"') && str.end_with?('"')
  214. str
  215. elsif str.start_with?("'") && str.end_with?("'")
  216. # Convert single quotes to double quotes
  217. inner = str[1..-2]
  218. "\"#{escape_string(inner)}\""
  219. else
  220. "\"#{escape_string(str)}\""
  221. end
  222. end
  223. 1 def escape_string(str)
  224. str.gsub('\\', '\\\\').gsub('"', '\\"')
  225. end
  226. 1 def format_color_value(value)
  227. if value.is_a?(String) && value.start_with?('#')
  228. hex = value.sub('#', '')
  229. if hex.length == 6
  230. "Color(0xFF#{hex.upcase})"
  231. elsif hex.length == 8
  232. "Color(0x#{hex.upcase})"
  233. else
  234. "Color.Unspecified"
  235. end
  236. else
  237. value.to_s
  238. end
  239. end
  240. end
  241. end
  242. end
  243. end

lib/hotloader/ip_monitor.rb

94.57% lines covered

92 relevant lines. 87 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'socket'
  3. 1 require 'json'
  4. 1 require 'fileutils'
  5. 1 module KjuiTools
  6. 1 module Hotloader
  7. 1 class IpMonitor
  8. 1 CONFIG_FILE = 'kjui.config.json'
  9. 1 CHECK_INTERVAL = 5 # seconds
  10. 1 def initialize(project_root = nil)
  11. 14 @project_root = project_root || find_project_root
  12. 14 @config_path = File.join(@project_root, CONFIG_FILE)
  13. 14 @running = false
  14. 14 @thread = nil
  15. 14 @last_ip = nil
  16. end
  17. 1 def start
  18. 4 return if @running
  19. 3 @running = true
  20. 3 @thread = Thread.new do
  21. 3 while @running
  22. 1 check_and_update_ip
  23. 1 sleep CHECK_INTERVAL
  24. end
  25. end
  26. 3 puts "IP Monitor started"
  27. end
  28. 1 def stop
  29. 3 @running = false
  30. 3 @thread&.join
  31. 3 puts "IP Monitor stopped"
  32. end
  33. 1 private
  34. 1 def find_project_root(start_path = Dir.pwd)
  35. 3 current = start_path
  36. # First check current and parent directories
  37. 3 while current != '/'
  38. 8 if File.exist?(File.join(current, CONFIG_FILE))
  39. 1 return current
  40. end
  41. # Check subdirectories for kjui.config.json
  42. 7 Dir.glob(File.join(current, '*', CONFIG_FILE)).each do |config_path|
  43. 1 if File.exist?(config_path)
  44. 1 return File.dirname(config_path)
  45. end
  46. end
  47. 6 current = File.dirname(current)
  48. end
  49. 1 Dir.pwd
  50. end
  51. 1 def check_and_update_ip
  52. 1 current_ip = get_local_ip
  53. 1 if current_ip && current_ip != @last_ip
  54. 1 update_config(current_ip)
  55. 1 update_android_configs(current_ip)
  56. 1 @last_ip = current_ip
  57. 1 puts "IP updated to: #{current_ip}"
  58. end
  59. rescue => e
  60. puts "Error checking IP: #{e.message}"
  61. end
  62. 1 def get_local_ip
  63. # Try to get WiFi IP first (common interface names)
  64. 2 interfaces = ['wlan0', 'wlp2s0', 'wlp3s0', 'en0', 'en1', 'eth0', 'eth1']
  65. 2 interfaces.each do |interface|
  66. 8 ip = get_interface_ip(interface)
  67. 8 return ip if ip && !ip.start_with?('127.')
  68. end
  69. # Fallback: get any non-localhost IP
  70. Socket.ip_address_list.each do |addr|
  71. if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  72. return addr.ip_address
  73. end
  74. end
  75. nil
  76. end
  77. 1 def get_interface_ip(interface)
  78. 8 Socket.getifaddrs.each do |ifaddr|
  79. 312 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  80. 2 return ifaddr.addr.ip_address
  81. end
  82. end
  83. nil
  84. rescue
  85. nil
  86. end
  87. 1 def update_config(ip)
  88. 3 config = if File.exist?(@config_path)
  89. 1 JSON.parse(File.read(@config_path))
  90. else
  91. 2 {}
  92. end
  93. 3 config['hotloader'] ||= {}
  94. 3 config['hotloader']['ip'] = ip
  95. 3 config['hotloader']['port'] ||= 8081
  96. 3 config['hotloader']['enabled'] = true
  97. 3 File.write(@config_path, JSON.pretty_generate(config))
  98. end
  99. 1 def update_android_configs(ip)
  100. # Load config to get port
  101. 2 config = if File.exist?(@config_path)
  102. 1 JSON.parse(File.read(@config_path))
  103. else
  104. 1 {}
  105. end
  106. 2 port = config.dig('hotloader', 'port') || 8081
  107. # Update local.properties if it exists
  108. 2 local_props = File.join(@project_root, 'local.properties')
  109. 2 if File.exist?(local_props)
  110. 1 content = File.read(local_props)
  111. # Remove old hotloader.ip line if exists
  112. 1 content.gsub!(/^hotloader\.ip=.*$/, '')
  113. 1 content.gsub!(/^hotloader\.port=.*$/, '')
  114. # Add new lines
  115. 1 content += "\nhotloader.ip=#{ip}"
  116. 1 content += "\nhotloader.port=#{port}"
  117. 1 File.write(local_props, content)
  118. end
  119. # Update any BuildConfig or resource files
  120. 2 update_build_config(ip)
  121. end
  122. 1 def update_build_config(ip)
  123. # Load config to get source directory and port
  124. 3 config = if File.exist?(@config_path)
  125. 2 JSON.parse(File.read(@config_path))
  126. else
  127. 1 {}
  128. end
  129. 3 source_dir = config['source_directory'] || 'src/main'
  130. 3 port = config.dig('hotloader', 'port') || 8081
  131. # Create or update hotloader config in assets
  132. 3 assets_dir = File.join(@project_root, source_dir, 'assets')
  133. 3 FileUtils.mkdir_p(assets_dir)
  134. 3 hotloader_config = File.join(assets_dir, 'hotloader.json')
  135. hotloader_json = {
  136. 3 'ip' => ip,
  137. 'port' => port,
  138. 'enabled' => true,
  139. 'websocket_endpoint' => "ws://#{ip}:#{port}",
  140. 'http_endpoint' => "http://#{ip}:#{port}"
  141. }
  142. 3 File.write(hotloader_config, JSON.pretty_generate(hotloader_json))
  143. end
  144. end
  145. end
  146. end

lib/xml/drawable/drawable_generator.rb

92.47% lines covered

93 relevant lines. 86 lines covered and 7 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'fileutils'
  3. 1 require_relative 'shape_drawable_generator'
  4. 1 require_relative 'ripple_drawable_generator'
  5. 1 require_relative 'state_list_drawable_generator'
  6. 1 require_relative 'drawable_hash_manager'
  7. 1 module DrawableGenerator
  8. 1 class Generator
  9. 1 def initialize(project_root)
  10. 59 @project_root = project_root
  11. # Check if we're already in a sample-app directory or need to look for one
  12. 59 if File.exist?(File.join(project_root, 'src', 'main', 'res'))
  13. 38 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  14. 21 elsif File.exist?(File.join(project_root, 'sample-app', 'src', 'main', 'res'))
  15. @drawable_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'drawable')
  16. 21 elsif File.exist?(File.join(project_root, 'app', 'src', 'main', 'res'))
  17. @drawable_dir = File.join(project_root, 'app', 'src', 'main', 'res', 'drawable')
  18. else
  19. # Default fallback
  20. 21 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  21. end
  22. 59 @hash_manager = DrawableHashManager.new(@drawable_dir)
  23. 59 @shape_generator = ShapeDrawableGenerator.new
  24. 59 @ripple_generator = RippleDrawableGenerator.new
  25. 59 @state_list_generator = StateListDrawableGenerator.new
  26. 59 ensure_drawable_directory
  27. end
  28. 1 def generate_for_component(json_data, component_type)
  29. 6 drawables = []
  30. # Check if we need a ripple effect drawable
  31. 6 if needs_ripple?(json_data, component_type)
  32. 4 drawable_name = generate_ripple_drawable(json_data, component_type)
  33. 4 drawables << drawable_name if drawable_name
  34. end
  35. # Check if we need a shape drawable
  36. 6 if needs_shape?(json_data)
  37. 3 drawable_name = generate_shape_drawable(json_data, component_type)
  38. 3 drawables << drawable_name if drawable_name
  39. end
  40. # Check if we need a state list drawable
  41. 6 if needs_state_list?(json_data)
  42. drawable_name = generate_state_list_drawable(json_data, component_type)
  43. drawables << drawable_name if drawable_name
  44. end
  45. 6 drawables.first # Return the primary drawable (usually state list or ripple)
  46. end
  47. 1 def get_background_drawable(json_data, component_type)
  48. 5 return nil unless json_data
  49. # Priority order: state list > ripple > shape > color
  50. 4 if needs_state_list?(json_data)
  51. 1 generate_state_list_drawable(json_data, component_type)
  52. 3 elsif needs_ripple?(json_data, component_type)
  53. 1 generate_ripple_drawable(json_data, component_type)
  54. 2 elsif needs_shape?(json_data)
  55. 1 generate_shape_drawable(json_data, component_type)
  56. else
  57. nil
  58. end
  59. end
  60. 1 private
  61. 1 def ensure_drawable_directory
  62. 59 FileUtils.mkdir_p(@drawable_dir) unless Dir.exist?(@drawable_dir)
  63. end
  64. 1 def needs_ripple?(json_data, component_type)
  65. 17 return false unless json_data
  66. # Check for click handlers
  67. 15 has_click_handler = json_data['onClick'] || json_data['onclick']
  68. # Certain component types should have ripple by default
  69. 15 clickable_components = ['Button', 'ImageButton', 'Card', 'ListItem']
  70. 15 is_clickable_component = clickable_components.include?(component_type)
  71. 15 has_click_handler || is_clickable_component
  72. end
  73. 1 def needs_shape?(json_data)
  74. 15 return false unless json_data
  75. # Check for shape-related attributes
  76. 13 json_data['cornerRadius'] ||
  77. json_data['borderWidth'] ||
  78. json_data['borderColor'] ||
  79. json_data['background']&.start_with?('#') ||
  80. json_data['gradient']
  81. end
  82. 1 def needs_state_list?(json_data)
  83. 17 return false unless json_data
  84. # Check for state-specific attributes
  85. 15 json_data['disabledBackground'] ||
  86. json_data['tapBackground'] ||
  87. json_data['selectedBackground'] ||
  88. json_data['pressedBackground'] ||
  89. json_data['focusedBackground']
  90. end
  91. 1 def generate_ripple_drawable(json_data, component_type)
  92. # Generate content based on attributes
  93. 5 drawable_content = @ripple_generator.generate(json_data, component_type)
  94. 5 return nil unless drawable_content
  95. # Generate hash-based filename
  96. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  97. 5 drawable_name = "ripple_#{drawable_hash}"
  98. # Check if drawable already exists
  99. 5 if @hash_manager.drawable_exists?(drawable_name)
  100. return drawable_name
  101. end
  102. # Write the drawable file
  103. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  104. 5 File.write(drawable_path, drawable_content)
  105. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  106. 5 drawable_name
  107. end
  108. 1 def generate_shape_drawable(json_data, component_type)
  109. # Generate content based on attributes
  110. 5 drawable_content = @shape_generator.generate(json_data)
  111. 5 return nil unless drawable_content
  112. # Generate hash-based filename
  113. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  114. 5 drawable_name = "shape_#{drawable_hash}"
  115. # Check if drawable already exists
  116. 5 if @hash_manager.drawable_exists?(drawable_name)
  117. return drawable_name
  118. end
  119. # Write the drawable file
  120. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  121. 5 File.write(drawable_path, drawable_content)
  122. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  123. 5 drawable_name
  124. end
  125. 1 def generate_state_list_drawable(json_data, component_type)
  126. # Generate content based on attributes
  127. 1 drawable_content = @state_list_generator.generate(json_data, self)
  128. 1 return nil unless drawable_content
  129. # Generate hash-based filename
  130. 1 drawable_hash = @hash_manager.generate_hash(drawable_content)
  131. 1 drawable_name = "selector_#{drawable_hash}"
  132. # Check if drawable already exists
  133. 1 if @hash_manager.drawable_exists?(drawable_name)
  134. return drawable_name
  135. end
  136. # Write the drawable file
  137. 1 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  138. 1 File.write(drawable_path, drawable_content)
  139. 1 @hash_manager.register_drawable(drawable_name, drawable_content)
  140. 1 drawable_name
  141. end
  142. # Public method for state list generator to create sub-drawables
  143. 1 def create_shape_drawable_for_state(state_data)
  144. 2 return nil unless state_data
  145. 1 generate_shape_drawable(state_data, nil)
  146. end
  147. end
  148. end

lib/xml/drawable/drawable_hash_manager.rb

47.76% lines covered

67 relevant lines. 32 lines covered and 35 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'json'
  3. 1 module DrawableGenerator
  4. 1 class DrawableHashManager
  5. 1 HASH_REGISTRY_FILE = '.drawable_hashes.json'
  6. 1 def initialize(drawable_dir)
  7. 59 @drawable_dir = drawable_dir
  8. 59 @registry_path = File.join(@drawable_dir, HASH_REGISTRY_FILE)
  9. 59 @registry = load_registry
  10. 59 @session_cache = {}
  11. end
  12. 1 def generate_hash(content)
  13. # Generate a short hash from the content
  14. 22 full_hash = Digest::SHA256.hexdigest(content)
  15. # Use first 8 characters for readability while maintaining uniqueness
  16. 22 full_hash[0..7]
  17. end
  18. 1 def drawable_exists?(drawable_name)
  19. # Check session cache first
  20. 11 return true if @session_cache[drawable_name]
  21. # Check file system
  22. 11 file_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  23. 11 exists = File.exist?(file_path)
  24. # Update cache if exists
  25. 11 @session_cache[drawable_name] = true if exists
  26. 11 exists
  27. end
  28. 1 def register_drawable(drawable_name, content)
  29. # Add to session cache
  30. 11 @session_cache[drawable_name] = true
  31. # Add to registry with metadata
  32. 11 @registry[drawable_name] = {
  33. 'hash' => generate_hash(content),
  34. 'created_at' => Time.now.to_s,
  35. 'content_hash' => Digest::MD5.hexdigest(content)
  36. }
  37. 11 save_registry
  38. end
  39. 1 def find_existing_drawable(content)
  40. content_hash = Digest::MD5.hexdigest(content)
  41. # Search registry for matching content
  42. @registry.each do |name, data|
  43. if data['content_hash'] == content_hash
  44. # Verify file still exists
  45. if drawable_exists?(name)
  46. return name
  47. else
  48. # Clean up orphaned registry entry
  49. @registry.delete(name)
  50. end
  51. end
  52. end
  53. nil
  54. end
  55. 1 def cleanup_orphaned_drawables
  56. orphaned = []
  57. @registry.each do |name, _data|
  58. file_path = File.join(@drawable_dir, "#{name}.xml")
  59. unless File.exist?(file_path)
  60. orphaned << name
  61. end
  62. end
  63. orphaned.each { |name| @registry.delete(name) }
  64. save_registry if orphaned.any?
  65. orphaned
  66. end
  67. 1 def list_drawables
  68. drawables = []
  69. Dir.glob(File.join(@drawable_dir, '*.xml')).each do |file|
  70. name = File.basename(file, '.xml')
  71. next if name == 'ic_launcher_foreground' # Skip system drawables
  72. next if name == 'ic_launcher_background'
  73. drawables << {
  74. name: name,
  75. path: file,
  76. size: File.size(file),
  77. modified: File.mtime(file)
  78. }
  79. end
  80. drawables.sort_by { |d| d[:name] }
  81. end
  82. 1 def get_usage_stats
  83. stats = {
  84. total_drawables: 0,
  85. shape_drawables: 0,
  86. ripple_drawables: 0,
  87. selector_drawables: 0,
  88. total_size: 0,
  89. reuse_count: 0
  90. }
  91. list_drawables.each do |drawable|
  92. stats[:total_drawables] += 1
  93. stats[:total_size] += drawable[:size]
  94. case drawable[:name]
  95. when /^shape_/
  96. stats[:shape_drawables] += 1
  97. when /^ripple_/
  98. stats[:ripple_drawables] += 1
  99. when /^selector_/
  100. stats[:selector_drawables] += 1
  101. end
  102. end
  103. # Count reuses based on session cache
  104. stats[:reuse_count] = @session_cache.size
  105. stats
  106. end
  107. 1 private
  108. 1 def load_registry
  109. 59 return {} unless File.exist?(@registry_path)
  110. begin
  111. JSON.parse(File.read(@registry_path))
  112. rescue JSON::ParserError
  113. {}
  114. end
  115. end
  116. 1 def save_registry
  117. 11 File.write(@registry_path, JSON.pretty_generate(@registry))
  118. rescue => e
  119. puts "Warning: Failed to save drawable registry: #{e.message}"
  120. end
  121. end
  122. end

lib/xml/drawable/ripple_drawable_generator.rb

96.0% lines covered

100 relevant lines. 96 lines covered and 4 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class RippleDrawableGenerator
  4. 1 def generate(json_data, component_type)
  5. 26 return nil unless json_data
  6. 25 xml = []
  7. 25 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. 25 xml << '<ripple xmlns:android="http://schemas.android.com/apk/res/android"'
  9. # Ripple color
  10. 25 ripple_color = determine_ripple_color(json_data, component_type)
  11. 25 xml << " android:color=\"#{ripple_color}\">"
  12. # Content mask and background
  13. 25 if needs_mask?(json_data, component_type)
  14. 10 generate_mask_content(xml, json_data)
  15. else
  16. 15 generate_background_content(xml, json_data)
  17. end
  18. 25 xml << '</ripple>'
  19. 25 xml.join("\n")
  20. end
  21. 1 private
  22. 1 def determine_ripple_color(json_data, component_type)
  23. # Check for explicit ripple color
  24. 25 if json_data['rippleColor']
  25. 1 return parse_color(json_data['rippleColor'])
  26. end
  27. # Check for tap background (can be used as ripple hint)
  28. 24 if json_data['tapBackground']
  29. 1 return parse_color(json_data['tapBackground'])
  30. end
  31. # Default ripple colors based on component type
  32. 23 case component_type
  33. when 'Button'
  34. 11 if json_data['background'] && json_data['background'].start_with?('#')
  35. # Light ripple for dark backgrounds, dark ripple for light
  36. 4 return is_dark_color?(json_data['background']) ? '#40FFFFFF' : '#40000000'
  37. end
  38. 7 return '?attr/colorControlHighlight'
  39. when 'Card', 'ListItem'
  40. 3 return '?attr/colorControlHighlight'
  41. else
  42. # Default semi-transparent ripple
  43. 9 return '#20000000'
  44. end
  45. end
  46. 1 def needs_mask?(json_data, component_type)
  47. # Use mask for borderless ripples or specific components
  48. 30 json_data['rippleBorderless'] == true ||
  49. component_type == 'ImageButton' ||
  50. 24 (component_type == 'Button' && !json_data['background'])
  51. end
  52. 1 def generate_mask_content(xml, json_data)
  53. 10 xml << ' <item android:id="@android:id/mask">'
  54. 10 if json_data['cornerRadius'] || json_data['shape']
  55. 1 xml << ' <shape android:shape="rectangle">'
  56. 1 if json_data['cornerRadius']
  57. 1 radius = parse_dimension(json_data['cornerRadius'])
  58. 1 xml << " <corners android:radius=\"#{radius}\" />"
  59. end
  60. 1 xml << ' <solid android:color="@android:color/white" />'
  61. 1 xml << ' </shape>'
  62. else
  63. 9 xml << ' <color android:color="@android:color/white" />'
  64. end
  65. 10 xml << ' </item>'
  66. end
  67. 1 def generate_background_content(xml, json_data)
  68. # Add background item if specified
  69. 15 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  70. 11 xml << ' <item>'
  71. 11 if json_data['cornerRadius'] || json_data['borderWidth']
  72. 3 generate_shape_item(xml, json_data)
  73. 8 elsif json_data['background']
  74. 8 if json_data['background'].start_with?('#')
  75. 7 xml << " <color android:color=\"#{json_data['background']}\" />"
  76. 1 elsif json_data['background'].start_with?('@')
  77. 1 xml << " <color android:color=\"#{json_data['background']}\" />"
  78. else
  79. color = parse_color(json_data['background'])
  80. xml << " <color android:color=\"#{color}\" />"
  81. end
  82. end
  83. 11 xml << ' </item>'
  84. end
  85. end
  86. 1 def generate_shape_item(xml, json_data)
  87. 3 xml << ' <shape android:shape="rectangle">'
  88. # Corner radius
  89. 3 if json_data['cornerRadius']
  90. 2 radius = parse_dimension(json_data['cornerRadius'])
  91. 2 xml << " <corners android:radius=\"#{radius}\" />"
  92. end
  93. # Background color
  94. 3 if json_data['background']
  95. 2 color = parse_color(json_data['background'])
  96. 2 xml << " <solid android:color=\"#{color}\" />"
  97. else
  98. 1 xml << ' <solid android:color="@android:color/transparent" />'
  99. end
  100. # Border
  101. 3 if json_data['borderWidth'] && json_data['borderColor']
  102. 1 width = parse_dimension(json_data['borderWidth'])
  103. 1 color = parse_color(json_data['borderColor'])
  104. 1 xml << ' <stroke'
  105. 1 xml << " android:width=\"#{width}\""
  106. 1 xml << " android:color=\"#{color}\" />"
  107. end
  108. 3 xml << ' </shape>'
  109. end
  110. 1 def is_dark_color?(color_str)
  111. 13 return false unless color_str&.start_with?('#')
  112. # Remove # and parse hex
  113. 11 hex = color_str[1..]
  114. # Handle different hex formats
  115. 11 if hex.length == 6
  116. 8 r = hex[0..1].to_i(16)
  117. 8 g = hex[2..3].to_i(16)
  118. 8 b = hex[4..5].to_i(16)
  119. 3 elsif hex.length == 8
  120. # Skip alpha
  121. 1 r = hex[2..3].to_i(16)
  122. 1 g = hex[4..5].to_i(16)
  123. 1 b = hex[6..7].to_i(16)
  124. 2 elsif hex.length == 3
  125. 2 r = (hex[0] * 2).to_i(16)
  126. 2 g = (hex[1] * 2).to_i(16)
  127. 2 b = (hex[2] * 2).to_i(16)
  128. else
  129. return false
  130. end
  131. # Calculate luminance
  132. 11 luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  133. 11 luminance < 0.5
  134. end
  135. 1 def parse_dimension(value)
  136. 7 return '0dp' unless value
  137. 6 value_str = value.to_s
  138. # Already has unit
  139. 6 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  140. # Just a number, add dp
  141. 5 return "#{value_str}dp" if value_str =~ /^\d+$/
  142. value_str
  143. end
  144. 1 def parse_color(value)
  145. 9 return '#000000' unless value
  146. 8 value_str = value.to_s
  147. # Already a color reference
  148. 8 return value_str if value_str.start_with?('@color/', '?attr/')
  149. # Special case for transparent
  150. 6 return '#00000000' if value_str == 'transparent'
  151. # Use ResourceResolver to check for color resources
  152. 5 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  153. end
  154. end
  155. end

lib/xml/drawable/shape_drawable_generator.rb

99.06% lines covered

106 relevant lines. 105 lines covered and 1 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class ShapeDrawableGenerator
  4. 1 def generate(json_data)
  5. 23 return nil unless json_data
  6. 22 xml = []
  7. 22 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. # Determine if we need a layer-list for gradient + border
  9. 22 if json_data['gradient'] && json_data['borderWidth']
  10. 2 generate_layered_shape(xml, json_data)
  11. else
  12. 20 generate_simple_shape(xml, json_data)
  13. end
  14. 22 xml.join("\n")
  15. end
  16. 1 private
  17. 1 def generate_simple_shape(xml, json_data)
  18. 20 xml << '<shape xmlns:android="http://schemas.android.com/apk/res/android"'
  19. 20 xml << ' android:shape="rectangle">'
  20. # Corner radius
  21. 20 if json_data['cornerRadius']
  22. 4 radius = parse_dimension(json_data['cornerRadius'])
  23. 4 xml << " <corners android:radius=\"#{radius}\" />"
  24. end
  25. # Background color or gradient
  26. 20 if json_data['gradient']
  27. 5 generate_gradient(xml, json_data['gradient'])
  28. 15 elsif json_data['background']
  29. 6 color = parse_color(json_data['background'])
  30. 6 xml << " <solid android:color=\"#{color}\" />"
  31. end
  32. # Border
  33. 20 if json_data['borderWidth'] && json_data['borderColor']
  34. 1 width = parse_dimension(json_data['borderWidth'])
  35. 1 color = parse_color(json_data['borderColor'])
  36. 1 xml << " <stroke"
  37. 1 xml << " android:width=\"#{width}\""
  38. 1 xml << " android:color=\"#{color}\" />"
  39. end
  40. # Padding
  41. 20 if json_data['padding']
  42. 1 padding = parse_dimension(json_data['padding'])
  43. 1 xml << " <padding"
  44. 1 xml << " android:left=\"#{padding}\""
  45. 1 xml << " android:top=\"#{padding}\""
  46. 1 xml << " android:right=\"#{padding}\""
  47. 1 xml << " android:bottom=\"#{padding}\" />"
  48. 19 elsif json_data['paddingLeft'] || json_data['paddingTop'] ||
  49. json_data['paddingRight'] || json_data['paddingBottom']
  50. 2 xml << " <padding"
  51. 2 xml << " android:left=\"#{parse_dimension(json_data['paddingLeft'] || '0dp')}\""
  52. 2 xml << " android:top=\"#{parse_dimension(json_data['paddingTop'] || '0dp')}\""
  53. 2 xml << " android:right=\"#{parse_dimension(json_data['paddingRight'] || '0dp')}\""
  54. 2 xml << " android:bottom=\"#{parse_dimension(json_data['paddingBottom'] || '0dp')}\" />"
  55. end
  56. 20 xml << '</shape>'
  57. end
  58. 1 def generate_layered_shape(xml, json_data)
  59. 2 xml << '<layer-list xmlns:android="http://schemas.android.com/apk/res/android">'
  60. # Background layer with gradient
  61. 2 xml << ' <item>'
  62. 2 xml << ' <shape android:shape="rectangle">'
  63. 2 if json_data['cornerRadius']
  64. 1 radius = parse_dimension(json_data['cornerRadius'])
  65. 1 xml << " <corners android:radius=\"#{radius}\" />"
  66. end
  67. 2 generate_gradient(xml, json_data['gradient'], ' ')
  68. 2 xml << ' </shape>'
  69. 2 xml << ' </item>'
  70. # Border layer
  71. 2 if json_data['borderWidth'] && json_data['borderColor']
  72. 2 xml << ' <item>'
  73. 2 xml << ' <shape android:shape="rectangle">'
  74. 2 if json_data['cornerRadius']
  75. 1 radius = parse_dimension(json_data['cornerRadius'])
  76. 1 xml << " <corners android:radius=\"#{radius}\" />"
  77. end
  78. 2 width = parse_dimension(json_data['borderWidth'])
  79. 2 color = parse_color(json_data['borderColor'])
  80. 2 xml << " <stroke"
  81. 2 xml << " android:width=\"#{width}\""
  82. 2 xml << " android:color=\"#{color}\" />"
  83. 2 xml << ' </shape>'
  84. 2 xml << ' </item>'
  85. end
  86. 2 xml << '</layer-list>'
  87. end
  88. 1 def generate_gradient(xml, gradient_data, indent = ' ')
  89. 7 return unless gradient_data
  90. # Parse gradient type
  91. 7 gradient_type = gradient_data['type'] || 'linear'
  92. 7 xml << "#{indent}<gradient"
  93. 7 case gradient_type.downcase
  94. when 'linear'
  95. 5 xml << "#{indent} android:type=\"linear\""
  96. 5 angle = gradient_data['angle'] || 0
  97. 5 xml << "#{indent} android:angle=\"#{angle}\""
  98. when 'radial'
  99. 1 xml << "#{indent} android:type=\"radial\""
  100. 1 radius = parse_dimension(gradient_data['radius'] || '100dp')
  101. 1 xml << "#{indent} android:gradientRadius=\"#{radius}\""
  102. when 'sweep'
  103. 1 xml << "#{indent} android:type=\"sweep\""
  104. end
  105. # Colors
  106. 7 if gradient_data['startColor']
  107. 4 color = parse_color(gradient_data['startColor'])
  108. 4 xml << "#{indent} android:startColor=\"#{color}\""
  109. end
  110. 7 if gradient_data['centerColor']
  111. 1 color = parse_color(gradient_data['centerColor'])
  112. 1 xml << "#{indent} android:centerColor=\"#{color}\""
  113. end
  114. 7 if gradient_data['endColor']
  115. 4 color = parse_color(gradient_data['endColor'])
  116. 4 xml << "#{indent} android:endColor=\"#{color}\""
  117. end
  118. # Center position for radial
  119. 7 if gradient_type.downcase == 'radial'
  120. 1 centerX = gradient_data['centerX'] || 0.5
  121. 1 centerY = gradient_data['centerY'] || 0.5
  122. 1 xml << "#{indent} android:centerX=\"#{centerX}\""
  123. 1 xml << "#{indent} android:centerY=\"#{centerY}\""
  124. end
  125. 7 xml << " />"
  126. end
  127. 1 def parse_dimension(value)
  128. 25 return '0dp' unless value
  129. 24 value_str = value.to_s
  130. # Already has unit
  131. 24 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  132. # Just a number, add dp
  133. 17 return "#{value_str}dp" if value_str =~ /^\d+$/
  134. value_str
  135. end
  136. 1 def parse_color(value)
  137. 21 return '#000000' unless value
  138. 20 value_str = value.to_s
  139. # Already a color reference
  140. 20 return value_str if value_str.start_with?('@color/')
  141. # Special case for transparent
  142. 19 return '#00000000' if value_str == 'transparent'
  143. # Use ResourceResolver to check for color resources
  144. 18 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  145. end
  146. end
  147. end

lib/xml/drawable/state_list_drawable_generator.rb

78.18% lines covered

110 relevant lines. 86 lines covered and 24 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class StateListDrawableGenerator
  4. 1 def generate(json_data, parent_generator)
  5. 20 return nil unless json_data
  6. 19 @parent_generator = parent_generator
  7. 19 xml = []
  8. 19 xml << '<?xml version="1.0" encoding="utf-8"?>'
  9. 19 xml << '<selector xmlns:android="http://schemas.android.com/apk/res/android">'
  10. # Order matters in state list - most specific states first
  11. # Disabled state
  12. 19 if json_data['disabledBackground'] || json_data['disabledColor']
  13. 3 generate_state_item(xml,
  14. state: 'disabled',
  15. background: json_data['disabledBackground'],
  16. color: json_data['disabledColor'],
  17. original_data: json_data
  18. )
  19. end
  20. # Pressed/Tap state
  21. 19 if json_data['tapBackground'] || json_data['pressedBackground'] || json_data['tapColor']
  22. 3 generate_state_item(xml,
  23. state: 'pressed',
  24. background: json_data['tapBackground'] || json_data['pressedBackground'],
  25. color: json_data['tapColor'],
  26. original_data: json_data
  27. )
  28. end
  29. # Selected state
  30. 19 if json_data['selectedBackground'] || json_data['selectedColor']
  31. 2 generate_state_item(xml,
  32. state: 'selected',
  33. background: json_data['selectedBackground'],
  34. color: json_data['selectedColor'],
  35. original_data: json_data
  36. )
  37. end
  38. # Focused state
  39. 19 if json_data['focusedBackground'] || json_data['focusedColor']
  40. 2 generate_state_item(xml,
  41. state: 'focused',
  42. background: json_data['focusedBackground'],
  43. color: json_data['focusedColor'],
  44. original_data: json_data
  45. )
  46. end
  47. # Checked state (for checkboxes, radio buttons, switches)
  48. 19 if json_data['checkedBackground'] || json_data['checkedColor']
  49. 2 generate_state_item(xml,
  50. state: 'checked',
  51. background: json_data['checkedBackground'],
  52. color: json_data['checkedColor'],
  53. original_data: json_data
  54. )
  55. end
  56. # Default state (always last)
  57. 19 generate_default_state(xml, json_data)
  58. 19 xml << '</selector>'
  59. 19 xml.join("\n")
  60. end
  61. 1 private
  62. 1 def generate_state_item(xml, state:, background:, color:, original_data:)
  63. 12 return unless background || color
  64. # Build state attributes
  65. 12 state_attrs = build_state_attributes(state)
  66. 12 xml << " <item #{state_attrs}>"
  67. 12 if background
  68. 7 if needs_shape?(background, original_data)
  69. # Generate a shape drawable for this state
  70. generate_state_shape(xml, background, original_data)
  71. else
  72. # Simple color
  73. 7 color_value = parse_color(background)
  74. 7 xml << " <color android:color=\"#{color_value}\" />"
  75. end
  76. 5 elsif color
  77. # Text color selector item
  78. 5 color_value = parse_color(color)
  79. 5 xml << " <color android:color=\"#{color_value}\" />"
  80. end
  81. 12 xml << ' </item>'
  82. end
  83. 1 def generate_default_state(xml, json_data)
  84. 19 xml << ' <item>'
  85. 19 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  86. 4 if needs_shape?(json_data['background'], json_data)
  87. 2 generate_state_shape(xml, json_data['background'], json_data)
  88. else
  89. # Simple color background
  90. 2 color = parse_color(json_data['background'] || '#FFFFFF')
  91. 2 xml << " <color android:color=\"#{color}\" />"
  92. end
  93. else
  94. # Transparent default
  95. 15 xml << ' <color android:color="@android:color/transparent" />'
  96. end
  97. 19 xml << ' </item>'
  98. end
  99. 1 def generate_state_shape(xml, background, original_data)
  100. 2 xml << ' <shape android:shape="rectangle">'
  101. # Corner radius from original data
  102. 2 if original_data['cornerRadius']
  103. 1 radius = parse_dimension(original_data['cornerRadius'])
  104. 1 xml << " <corners android:radius=\"#{radius}\" />"
  105. end
  106. # Background color or gradient
  107. 2 if background.is_a?(Hash) && background['gradient']
  108. generate_gradient(xml, background['gradient'])
  109. 2 elsif background
  110. 2 color = parse_color(background)
  111. 2 xml << " <solid android:color=\"#{color}\" />"
  112. end
  113. # Border from original data (consistent across states)
  114. 2 if original_data['borderWidth'] && original_data['borderColor']
  115. 1 width = parse_dimension(original_data['borderWidth'])
  116. 1 color = parse_color(original_data['borderColor'])
  117. 1 xml << ' <stroke'
  118. 1 xml << " android:width=\"#{width}\""
  119. 1 xml << " android:color=\"#{color}\" />"
  120. end
  121. 2 xml << ' </shape>'
  122. end
  123. 1 def generate_gradient(xml, gradient_data)
  124. return unless gradient_data
  125. gradient_type = gradient_data['type'] || 'linear'
  126. xml << ' <gradient'
  127. case gradient_type.downcase
  128. when 'linear'
  129. xml << ' android:type="linear"'
  130. angle = gradient_data['angle'] || 0
  131. xml << " android:angle=\"#{angle}\""
  132. when 'radial'
  133. xml << ' android:type="radial"'
  134. radius = parse_dimension(gradient_data['radius'] || '100dp')
  135. xml << " android:gradientRadius=\"#{radius}\""
  136. when 'sweep'
  137. xml << ' android:type="sweep"'
  138. end
  139. # Colors
  140. if gradient_data['startColor']
  141. color = parse_color(gradient_data['startColor'])
  142. xml << " android:startColor=\"#{color}\""
  143. end
  144. if gradient_data['centerColor']
  145. color = parse_color(gradient_data['centerColor'])
  146. xml << " android:centerColor=\"#{color}\""
  147. end
  148. if gradient_data['endColor']
  149. color = parse_color(gradient_data['endColor'])
  150. xml << " android:endColor=\"#{color}\""
  151. end
  152. xml << ' />'
  153. end
  154. 1 def build_state_attributes(state)
  155. 19 case state
  156. when 'disabled'
  157. 4 'android:state_enabled="false"'
  158. when 'pressed'
  159. 4 'android:state_pressed="true"'
  160. when 'selected'
  161. 3 'android:state_selected="true"'
  162. when 'focused'
  163. 3 'android:state_focused="true"'
  164. when 'checked'
  165. 3 'android:state_checked="true"'
  166. when 'activated'
  167. 1 'android:state_activated="true"'
  168. else
  169. 1 ''
  170. end
  171. end
  172. 1 def needs_shape?(background, original_data)
  173. 15 return true if original_data['cornerRadius']
  174. 13 return true if original_data['borderWidth']
  175. 11 return true if background.is_a?(Hash) && background['gradient']
  176. 10 false
  177. end
  178. 1 def parse_dimension(value)
  179. 5 return '0dp' unless value
  180. 4 value_str = value.to_s
  181. # Already has unit
  182. 4 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  183. # Just a number, add dp
  184. 3 return "#{value_str}dp" if value_str =~ /^\d+$/
  185. value_str
  186. end
  187. 1 def parse_color(value)
  188. 21 return '#000000' unless value
  189. 20 value_str = value.to_s
  190. # Already a color reference
  191. 20 return value_str if value_str.start_with?('@color/', '?attr/')
  192. # Special case for transparent
  193. 18 return '#00000000' if value_str == 'transparent'
  194. # Use ResourceResolver to check for color resources
  195. 17 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  196. end
  197. end
  198. end

lib/xml/helpers/attribute_mapper.rb

92.11% lines covered

76 relevant lines. 70 lines covered and 6 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require_relative 'mappers/dimension_mapper'
  4. 1 require_relative 'mappers/text_mapper'
  5. 1 require_relative 'mappers/layout_mapper'
  6. 1 require_relative 'mappers/style_mapper'
  7. 1 require_relative 'mappers/input_mapper'
  8. 1 module XmlGenerator
  9. 1 class AttributeMapper
  10. 1 def initialize(drawable_generator = nil, string_resource_manager = nil)
  11. 64 @dimension_mapper = Mappers::DimensionMapper.new
  12. 64 @text_mapper = Mappers::TextMapper.new(string_resource_manager)
  13. 64 @layout_mapper = Mappers::LayoutMapper.new(@dimension_mapper)
  14. 64 @style_mapper = Mappers::StyleMapper.new(@text_mapper, drawable_generator)
  15. 64 @input_mapper = Mappers::InputMapper.new
  16. 64 @drawable_generator = drawable_generator
  17. 64 @string_resource_manager = string_resource_manager
  18. 64 @attribute_map = create_attribute_map
  19. end
  20. 1 def map_dimension(value)
  21. 30 @dimension_mapper.map_dimension(value)
  22. end
  23. 1 def map_attribute(key, value, component_type, parent_type = nil, json_element = nil)
  24. # Skip problematic data binding expressions for specific attributes
  25. 25 if should_skip_binding?(key, value, component_type)
  26. 4 log_skipped_binding(key, value, component_type)
  27. 4 puts "Skipping binding: #{key}=#{value} for #{component_type}" if ENV['DEBUG']
  28. 4 return nil
  29. end
  30. # Try layout attributes first (includes dimensions, padding, margin, alignment)
  31. 21 result = @layout_mapper.map_layout_attributes(key, value, component_type, parent_type)
  32. 21 return result if result
  33. # Try alignment attributes
  34. 20 result = @layout_mapper.map_alignment_attributes(key, value, parent_type)
  35. 20 return result if result
  36. # Try text attributes
  37. 18 result = @text_mapper.map_text_attributes(key, value, component_type)
  38. 18 return result if result
  39. # Try style attributes (with json_element for drawable generation)
  40. 15 result = @style_mapper.map_style_attributes(key, value, json_element, component_type)
  41. 15 return result if result
  42. # Try input attributes
  43. 14 result = @input_mapper.map_input_attributes(key, value)
  44. 14 return result if result
  45. # Custom component properties (store as tag or tools attribute)
  46. 14 case key
  47. when 'title'
  48. # Don't use tools: namespace for data binding expressions
  49. 2 if value.to_s.start_with?('@{')
  50. 1 return nil # Skip tools attributes with data binding
  51. end
  52. 1 return { namespace: 'tools', name: 'title', value: value }
  53. when 'count'
  54. # Don't use tools: namespace for data binding expressions
  55. 1 if value.to_s.start_with?('@{')
  56. return nil # Skip tools attributes with data binding
  57. end
  58. 1 return { namespace: 'tools', name: 'count', value: value.to_s }
  59. when /^constraint/
  60. 3 return map_constraint_attribute(key, value)
  61. else
  62. # Check if it's in the standard map
  63. 8 if @attribute_map[key]
  64. 5 mapped = @attribute_map[key]
  65. return {
  66. 5 namespace: mapped[:namespace] || 'android',
  67. name: mapped[:name],
  68. value: convert_value(value, mapped[:type])
  69. }
  70. end
  71. end
  72. nil
  73. end
  74. 1 private
  75. 1 def should_skip_binding?(key, value, component_type)
  76. 25 return false unless value.to_s.include?('@{')
  77. # List of problematic bindings that need to be skipped
  78. problematic_bindings = [
  79. # RecyclerView items binding - Skip this as it needs complex adapter implementation
  80. 5 { key: 'items', component: 'RecyclerView' },
  81. { key: 'items', component: 'Collection' },
  82. # StatusColor binding - Compose UI Color type not supported in data binding
  83. { key: 'tint', value_contains: 'statusColor' },
  84. { key: 'color', value_contains: 'statusColor' }, # color is sometimes mapped to tint
  85. # Visibility binding - String type not supported
  86. { key: 'visibility', value_contains: '@{' },
  87. # Progress binding - double type not supported
  88. { key: 'progress', value_contains: '@{' },
  89. # Slider value binding (maps to progress) - double type not supported
  90. { key: 'value', component: 'Slider', value_contains: '@{' }
  91. ]
  92. 5 problematic_bindings.any? do |binding|
  93. 22 if binding[:component]
  94. 10 key == binding[:key] && component_type&.include?(binding[:component])
  95. 12 elsif binding[:value_contains]
  96. 12 key == binding[:key] && value.to_s.include?(binding[:value_contains])
  97. elsif binding[:type]
  98. key == binding[:key] && value.to_s.include?('.') # Assumes object property access
  99. else
  100. key == binding[:key]
  101. end
  102. end
  103. end
  104. 1 def log_skipped_binding(key, value, component_type)
  105. 4 @skipped_bindings ||= []
  106. 4 @skipped_bindings << {
  107. attribute: key,
  108. value: value,
  109. component: component_type,
  110. reason: 'Requires custom binding adapter'
  111. }
  112. # Write to a file that can be accessed later
  113. 4 File.open('/tmp/skipped_bindings.json', 'w') do |f|
  114. 4 f.write(@skipped_bindings.to_json)
  115. end
  116. end
  117. 1 def create_attribute_map
  118. {
  119. # Additional mappings not covered by specific mappers
  120. 64 'contentDescription' => { name: 'contentDescription', type: 'string' },
  121. 'tag' => { name: 'tag', type: 'string' },
  122. 'transitionName' => { name: 'transitionName', type: 'string' },
  123. 'elevation' => { name: 'elevation', type: 'dimension' },
  124. 'translationZ' => { name: 'translationZ', type: 'dimension' },
  125. 'rotation' => { name: 'rotation', type: 'float' },
  126. 'rotationX' => { name: 'rotationX', type: 'float' },
  127. 'rotationY' => { name: 'rotationY', type: 'float' },
  128. 'scaleX' => { name: 'scaleX', type: 'float' },
  129. 'scaleY' => { name: 'scaleY', type: 'float' }
  130. }
  131. end
  132. 1 def convert_value(value, type)
  133. 5 case type
  134. when 'dimension'
  135. 1 @dimension_mapper.convert_dimension(value)
  136. when 'float'
  137. 2 value.to_f.to_s
  138. when 'integer'
  139. value.to_i.to_s
  140. when 'boolean'
  141. value.to_s
  142. else
  143. 2 value
  144. end
  145. end
  146. 1 def map_constraint_attribute(key, value)
  147. # ConstraintLayout attributes mapping
  148. constraint_map = {
  149. 3 'constraintStartToStartOf' => 'layout_constraintStart_toStartOf',
  150. 'constraintEndToEndOf' => 'layout_constraintEnd_toEndOf',
  151. 'constraintTopToTopOf' => 'layout_constraintTop_toTopOf',
  152. 'constraintBottomToBottomOf' => 'layout_constraintBottom_toBottomOf',
  153. 'constraintStartToEndOf' => 'layout_constraintStart_toEndOf',
  154. 'constraintEndToStartOf' => 'layout_constraintEnd_toStartOf',
  155. 'constraintTopToBottomOf' => 'layout_constraintTop_toBottomOf',
  156. 'constraintBottomToTopOf' => 'layout_constraintBottom_toTopOf'
  157. }
  158. 3 if constraint_map[key]
  159. 3 constraint_value = value == 'parent' ? 'parent' : "@id/#{value}"
  160. 3 return { namespace: 'app', name: constraint_map[key], value: constraint_value }
  161. end
  162. nil
  163. end
  164. end
  165. end

lib/xml/helpers/binding_parser.rb

75.9% lines covered

83 relevant lines. 63 lines covered and 20 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class BindingParser
  4. 1 def initialize
  5. 34 @bindings = []
  6. end
  7. 1 def parse(value)
  8. # Convert @{variable} syntax to Android data binding
  9. 11 if value.start_with?('@{') && value.end_with?('}')
  10. # Extract the binding expression
  11. 10 expression = value[2..-2]
  12. # Track the binding
  13. 10 @bindings << expression
  14. # Return Android data binding format
  15. 10 "@{#{convert_expression(expression)}}"
  16. else
  17. 1 value
  18. end
  19. end
  20. 1 def get_bindings
  21. 1 @bindings.uniq
  22. end
  23. 1 def has_bindings?
  24. 2 !@bindings.empty?
  25. end
  26. 1 private
  27. 1 def convert_expression(expression)
  28. # Handle different binding patterns
  29. # Simple variable binding: @{userName} -> @{data.userName}
  30. 10 if expression.match?(/^\w+$/)
  31. 5 return "data.#{expression}"
  32. end
  33. # Property access: @{user.name} -> @{data.user.name}
  34. 5 if expression.match?(/^[\w.]+$/)
  35. 1 return "data.#{expression}"
  36. end
  37. # Method call: @{getUserName()} -> @{viewModel.getUserName()}
  38. 4 if expression.include?('(')
  39. 2 if expression.start_with?('viewModel.')
  40. 1 return expression
  41. else
  42. 1 return "viewModel.#{expression}"
  43. end
  44. end
  45. # Conditional expression: @{isVisible ? View.VISIBLE : View.GONE}
  46. 2 if expression.include?('?')
  47. 1 return process_conditional(expression)
  48. end
  49. # String concatenation: @{`Hello ${userName}`}
  50. 1 if expression.include?('${')
  51. 1 return process_string_template(expression)
  52. end
  53. # Default: return as is
  54. expression
  55. end
  56. 1 def process_conditional(expression)
  57. # Convert conditional expressions
  58. 1 parts = expression.split(/\s*\?\s*/)
  59. 1 if parts.length == 2
  60. 1 condition = parts[0]
  61. 1 values = parts[1].split(/\s*:\s*/)
  62. 1 if values.length == 2
  63. # Add data. prefix to condition if it's a simple variable
  64. 1 if condition.match?(/^\w+$/)
  65. 1 condition = "data.#{condition}"
  66. end
  67. # Process visibility values
  68. 1 true_value = process_value(values[0])
  69. 1 false_value = process_value(values[1])
  70. 1 return "#{condition} ? #{true_value} : #{false_value}"
  71. end
  72. end
  73. expression
  74. end
  75. 1 def process_string_template(expression)
  76. # Convert string template: `Hello ${userName}` -> @{`Hello ` + data.userName}
  77. 1 if expression.start_with?('`') && expression.end_with?('`')
  78. 1 template = expression[1..-2]
  79. # Replace ${variable} with ` + data.variable + `
  80. 1 template.gsub!(/\$\{(\w+)\}/) do |match|
  81. 1 "` + data.#{$1} + `"
  82. end
  83. 1 "`#{template}`"
  84. else
  85. expression
  86. end
  87. end
  88. 1 def process_value(value)
  89. # Process special values
  90. 2 case value.strip
  91. when 'true', 'false'
  92. value
  93. when 'VISIBLE', 'View.VISIBLE'
  94. 1 'View.VISIBLE'
  95. when 'INVISIBLE', 'View.INVISIBLE'
  96. 'View.INVISIBLE'
  97. when 'GONE', 'View.GONE'
  98. 1 'View.GONE'
  99. else
  100. # Check if it's a simple variable
  101. if value.match?(/^\w+$/)
  102. "data.#{value}"
  103. else
  104. value
  105. end
  106. end
  107. end
  108. end
  109. 1 class DataBindingManager
  110. 1 def initialize
  111. 3 @variables = Set.new
  112. 3 @imports = Set.new
  113. 3 @converters = []
  114. end
  115. 1 def add_variable(name, type = 'String')
  116. 1 @variables.add({ name: name, type: type })
  117. end
  118. 1 def add_import(class_name)
  119. 1 @imports.add(class_name)
  120. end
  121. 1 def add_converter(converter)
  122. 1 @converters << converter
  123. end
  124. 1 def generate_data_binding_layout(xml_content)
  125. # Wrap the layout in <layout> tags for data binding
  126. doc = Nokogiri::XML(xml_content)
  127. # Create new document with layout root
  128. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  129. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  130. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  131. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  132. # Add data section
  133. xml.data do
  134. # Add imports
  135. @imports.each do |import|
  136. xml.import(type: import)
  137. end
  138. # Add variables
  139. @variables.each do |var|
  140. xml.variable(name: var[:name], type: var[:type])
  141. end
  142. # Add ViewModel variable
  143. xml.variable(name: 'viewModel', type: "com.example.viewmodel.#{get_view_model_name}")
  144. end
  145. # Add the original layout content (without XML declaration)
  146. xml << doc.root.to_xml
  147. end
  148. end
  149. builder.to_xml(indent: 4)
  150. end
  151. 1 private
  152. 1 def get_view_model_name
  153. # Generate ViewModel class name from layout name
  154. # This should be passed in or configured
  155. 'MainViewModel'
  156. end
  157. end
  158. end

lib/xml/helpers/component_mapper.rb

93.94% lines covered

33 relevant lines. 31 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class ComponentMapper
  4. 1 def initialize
  5. @component_map = {
  6. # Layout containers
  7. # Note: 'View' is handled specially in map_component method
  8. 61 'HStack' => 'LinearLayout',
  9. 'VStack' => 'LinearLayout',
  10. 'ZStack' => 'FrameLayout',
  11. 'RelativeView' => 'RelativeLayout',
  12. 'ConstraintView' => 'androidx.constraintlayout.widget.ConstraintLayout',
  13. 'ScrollView' => 'ScrollView',
  14. 'HorizontalScrollView' => 'HorizontalScrollView',
  15. # Basic components - Use Kjui custom views for font support
  16. 'Label' => 'com.kotlinjsonui.views.KjuiTextView',
  17. 'Text' => 'com.kotlinjsonui.views.KjuiTextView',
  18. 'Button' => 'com.kotlinjsonui.views.KjuiButton',
  19. 'ImageButton' => 'ImageButton',
  20. 'TextField' => 'com.kotlinjsonui.views.KjuiEditText',
  21. 'SecureField' => 'com.kotlinjsonui.views.KjuiEditText',
  22. 'TextView' => 'com.kotlinjsonui.views.KjuiEditText',
  23. # Images
  24. 'Image' => 'ImageView',
  25. 'NetworkImage' => 'com.kotlinjsonui.views.KjuiNetworkImageView',
  26. 'CircleImage' => 'com.kotlinjsonui.views.KjuiCircleImageView',
  27. # Selection components
  28. 'Switch' => 'Switch',
  29. 'Checkbox' => 'CheckBox',
  30. 'Radio' => 'RadioButton',
  31. 'RadioGroup' => 'RadioGroup',
  32. 'Segment' => 'com.google.android.material.tabs.TabLayout',
  33. 'Picker' => 'Spinner',
  34. 'SelectBox' => 'com.kotlinjsonui.views.KjuiSelectBox',
  35. 'DatePicker' => 'DatePicker',
  36. 'TimePicker' => 'TimePicker',
  37. # Progress
  38. 'ProgressBar' => 'ProgressBar',
  39. 'Slider' => 'SeekBar',
  40. 'Rating' => 'RatingBar',
  41. # Lists
  42. 'List' => 'androidx.recyclerview.widget.RecyclerView',
  43. 'Table' => 'androidx.recyclerview.widget.RecyclerView',
  44. 'Collection' => 'androidx.recyclerview.widget.RecyclerView',
  45. 'Grid' => 'GridLayout',
  46. # Material Design components
  47. 'Card' => 'com.google.android.material.card.MaterialCardView',
  48. 'Chip' => 'com.google.android.material.chip.Chip',
  49. 'ChipGroup' => 'com.google.android.material.chip.ChipGroup',
  50. 'FloatingActionButton' => 'com.google.android.material.floatingactionbutton.FloatingActionButton',
  51. 'BottomNavigation' => 'com.google.android.material.bottomnavigation.BottomNavigationView',
  52. 'NavigationView' => 'com.google.android.material.navigation.NavigationView',
  53. 'AppBar' => 'com.google.android.material.appbar.AppBarLayout',
  54. 'Toolbar' => 'androidx.appcompat.widget.Toolbar',
  55. 'TabLayout' => 'com.google.android.material.tabs.TabLayout',
  56. 'TabView' => 'com.google.android.material.tabs.TabLayout',
  57. # Special components
  58. 'SafeAreaView' => 'com.kotlinjsonui.views.KjuiSafeAreaView',
  59. 'GradientView' => 'com.kotlinjsonui.views.KjuiGradientView',
  60. 'BlurView' => 'com.kotlinjsonui.views.KjuiBlurView',
  61. 'WebView' => 'WebView',
  62. 'VideoView' => 'VideoView',
  63. 'MapView' => 'com.google.android.gms.maps.MapView',
  64. 'AdView' => 'com.google.android.gms.ads.AdView',
  65. # Dividers and spacers
  66. 'Divider' => 'View',
  67. 'Spacer' => 'Space',
  68. # Custom components (will be replaced with includes)
  69. 'Include' => 'include'
  70. }
  71. end
  72. 1 def map_component(type, json_element = nil)
  73. # Special handling for View type
  74. 21 if type == 'View' && json_element
  75. # Check if orientation is specified
  76. 5 if json_element['orientation']
  77. 1 return 'LinearLayout'
  78. else
  79. # Use ConstraintLayout instead of RelativeLayout for better positioning support
  80. 4 return 'androidx.constraintlayout.widget.ConstraintLayout'
  81. end
  82. end
  83. # Check for custom component prefix
  84. 16 if type.start_with?('Custom')
  85. 1 return 'include'
  86. end
  87. # For unknown types, check if they have children
  88. 15 if !@component_map[type] && json_element && (json_element['child'] || json_element['children'])
  89. 1 return 'FrameLayout'
  90. end
  91. 14 @component_map[type] || 'View'
  92. end
  93. 1 def is_container?(type)
  94. 8 containers = ['View', 'HStack', 'VStack', 'ZStack', 'ScrollView',
  95. 'HorizontalScrollView', 'RelativeView', 'ConstraintView',
  96. 'Card', 'List', 'Table', 'Collection', 'Grid',
  97. 'RadioGroup', 'ChipGroup']
  98. 8 containers.include?(type)
  99. end
  100. 1 def needs_adapter?(type)
  101. 4 ['List', 'Table', 'Collection', 'RecyclerView'].include?(type)
  102. end
  103. 1 def is_material_component?(android_class)
  104. 2 android_class.include?('com.google.android.material')
  105. end
  106. 1 def get_layout_params_class(parent_type)
  107. 4 case parent_type
  108. when 'RelativeLayout', 'RelativeView'
  109. 1 'RelativeLayout.LayoutParams'
  110. when 'LinearLayout', 'View', 'HStack', 'VStack'
  111. 1 'LinearLayout.LayoutParams'
  112. when 'FrameLayout', 'ZStack'
  113. 1 'FrameLayout.LayoutParams'
  114. when 'ConstraintLayout', 'ConstraintView'
  115. 1 'ConstraintLayout.LayoutParams'
  116. when 'GridLayout', 'Grid'
  117. 'GridLayout.LayoutParams'
  118. else
  119. 'ViewGroup.LayoutParams'
  120. end
  121. end
  122. 1 def get_orientation(type)
  123. 3 case type
  124. when 'HStack'
  125. 1 'horizontal'
  126. when 'VStack', 'View'
  127. 1 'vertical'
  128. else
  129. nil
  130. end
  131. end
  132. end
  133. end

lib/xml/helpers/data_binding_helper.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class DataBindingHelper
  4. 1 def self.process_data_binding(value)
  5. 11 return nil if value.nil?
  6. # Convert @{variable} to Android data binding format
  7. 10 if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  8. # Already in binding format, just ensure proper data. prefix
  9. 7 expr = value[2..-2]
  10. # Add data. prefix if it's a simple variable
  11. 7 if expr.match?(/^\w+$/)
  12. 2 "@{data.#{expr}}"
  13. 5 elsif expr.include?('(') && !expr.include?('viewModel.')
  14. # Method call without viewModel prefix
  15. 2 "@{viewModel.#{expr}}"
  16. else
  17. # Keep as is (already has proper prefix or is complex expression)
  18. 3 value
  19. end
  20. else
  21. 3 value
  22. end
  23. end
  24. end
  25. end

lib/xml/helpers/layout_attribute_processor.rb

76.34% lines covered

93 relevant lines. 71 lines covered and 22 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative 'data_binding_helper'
  3. 1 module XmlGenerator
  4. 1 class LayoutAttributeProcessor
  5. 1 def initialize(attribute_mapper)
  6. 42 @attribute_mapper = attribute_mapper
  7. end
  8. # Process layout dimensions with weight support
  9. 1 def process_dimensions(json_element, is_root, parent_orientation)
  10. 13 attrs = {}
  11. 13 has_weight = json_element['weight']
  12. # Default dimensions
  13. 13 default_width = 'wrap_content'
  14. 13 default_height = 'wrap_content'
  15. # If root element, default to match_parent
  16. 13 if is_root
  17. 4 default_width = 'match_parent'
  18. 4 default_height = 'match_parent'
  19. # If weight is specified, set the dimension in the orientation direction to 0dp
  20. 9 elsif has_weight && parent_orientation
  21. 4 if parent_orientation == 'horizontal'
  22. 2 default_width = '0dp' if !json_element['width']
  23. 2 elsif parent_orientation == 'vertical'
  24. 2 default_height = '0dp' if !json_element['height']
  25. end
  26. end
  27. 13 attrs['android:layout_width'] = @attribute_mapper.map_dimension(
  28. json_element['width'] || default_width
  29. )
  30. 13 attrs['android:layout_height'] = @attribute_mapper.map_dimension(
  31. json_element['height'] || default_height
  32. )
  33. 13 attrs
  34. end
  35. # Process all attributes with gravity combination support
  36. 1 def process_attributes(json_element, parent_type)
  37. 12 attrs = {}
  38. 12 gravity_values = []
  39. 12 constraint_extras = []
  40. 12 has_constraint_specified = false
  41. # Check if parent is ConstraintLayout
  42. 12 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  43. # Track which constraints have been set
  44. 12 constraint_flags = {
  45. horizontal: false,
  46. vertical: false
  47. }
  48. # Map all attributes
  49. 12 json_element.each do |key, value|
  50. 23 next if ['type', 'child', 'children', 'id', 'width', 'height', 'style', 'data', 'orientation'].include?(key)
  51. 9 android_attr = @attribute_mapper.map_attribute(key, value, json_element['type'], parent_type, json_element)
  52. 9 if android_attr
  53. 7 namespace, attr_name = android_attr[:namespace], android_attr[:name]
  54. 7 attr_value = android_attr[:value]
  55. 7 extra = android_attr[:extra]
  56. # Handle data binding
  57. 7 if attr_value.is_a?(String) && attr_value.start_with?('@{')
  58. attr_value = DataBindingHelper.process_data_binding(attr_value)
  59. end
  60. # Track if constraint attributes are being set
  61. 7 if is_constraint_layout && namespace == 'app'
  62. 2 if attr_name.include?('constraint')
  63. 2 has_constraint_specified = true
  64. # Track horizontal constraints
  65. 2 if attr_name.include?('Start') || attr_name.include?('End') || attr_name.include?('Left') || attr_name.include?('Right')
  66. 1 constraint_flags[:horizontal] = true
  67. end
  68. # Track vertical constraints
  69. 2 if attr_name.include?('Top') || attr_name.include?('Bottom')
  70. 1 constraint_flags[:vertical] = true
  71. end
  72. end
  73. end
  74. # Check for alignment attributes that map to constraints
  75. 7 if is_constraint_layout && ['alignLeft', 'alignRight', 'alignTop', 'alignBottom', 'alignCenterHorizontal', 'alignCenterVertical', 'alignCenterInParent'].include?(key)
  76. 2 has_constraint_specified = true
  77. 2 if key == 'alignLeft' || key == 'alignRight' || key == 'alignCenterHorizontal'
  78. 1 constraint_flags[:horizontal] = true
  79. end
  80. 2 if key == 'alignTop' || key == 'alignBottom' || key == 'alignCenterVertical'
  81. 1 constraint_flags[:vertical] = true
  82. end
  83. 2 if key == 'alignCenterInParent'
  84. constraint_flags[:horizontal] = true
  85. constraint_flags[:vertical] = true
  86. end
  87. end
  88. # Collect gravity values to combine them
  89. 7 if attr_name == 'layout_gravity' && parent_type == 'LinearLayout'
  90. gravity_values << attr_value if value
  91. 7 elsif extra && is_constraint_layout
  92. # Handle special ConstraintLayout cases that need multiple attributes
  93. constraint_extras << { key: key, value: value, extra: extra }
  94. # Still add the primary attribute
  95. if namespace == 'android'
  96. attrs["android:#{attr_name}"] = attr_value
  97. elsif namespace == 'app'
  98. attrs["app:#{attr_name}"] = attr_value
  99. end
  100. else
  101. 7 if namespace == 'android'
  102. 5 attrs["android:#{attr_name}"] = attr_value
  103. 2 elsif namespace == 'app'
  104. 2 attrs["app:#{attr_name}"] = attr_value
  105. elsif namespace == 'tools'
  106. attrs["tools:#{attr_name}"] = attr_value
  107. else
  108. attrs[attr_name] = attr_value
  109. end
  110. end
  111. end
  112. end
  113. # Process ConstraintLayout special cases
  114. 12 if is_constraint_layout && constraint_extras.any?
  115. constraint_extras.each do |item|
  116. case item[:extra]
  117. when 'center_horizontal'
  118. # Add end constraint for horizontal centering
  119. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  120. when 'center_vertical'
  121. # Add bottom constraint for vertical centering
  122. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  123. when 'center_in_parent'
  124. # Add all constraints for centering in parent
  125. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  126. attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  127. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  128. when 'center_vertical_to_view'
  129. # Add bottom constraint to same view for vertical centering
  130. attrs['app:layout_constraintBottom_toBottomOf'] = "@id/#{item[:value]}"
  131. when 'center_horizontal_to_view'
  132. # Add end constraint to same view for horizontal centering
  133. attrs['app:layout_constraintEnd_toEndOf'] = "@id/#{item[:value]}"
  134. end
  135. end
  136. end
  137. # Add default constraints for ConstraintLayout if none specified
  138. 12 if is_constraint_layout
  139. # Add default horizontal constraint (top-left) if no horizontal constraint specified
  140. 6 if !constraint_flags[:horizontal]
  141. 5 attrs['app:layout_constraintStart_toStartOf'] = 'parent'
  142. end
  143. # Add default vertical constraint (top) if no vertical constraint specified
  144. 6 if !constraint_flags[:vertical]
  145. 5 attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  146. end
  147. end
  148. # Combine gravity values if there are multiple
  149. 12 if gravity_values.any?
  150. attrs['android:layout_gravity'] = gravity_values.join('|')
  151. end
  152. 12 attrs
  153. end
  154. # Process LinearLayout orientation
  155. 1 def process_orientation(view_class, json_element)
  156. 8 attrs = {}
  157. 8 if view_class == 'LinearLayout' && json_element['orientation']
  158. 1 attrs['android:orientation'] = json_element['orientation']
  159. 7 elsif view_class == 'LinearLayout'
  160. # Default to vertical if not specified
  161. 1 attrs['android:orientation'] = 'vertical'
  162. end
  163. 8 attrs
  164. end
  165. end
  166. end

lib/xml/helpers/mappers/dimension_mapper.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class DimensionMapper
  5. 1 def map_dimension(value)
  6. # Handle nil or empty string
  7. 47 return 'wrap_content' if value.nil? || value.to_s.empty?
  8. 45 case value
  9. when 'matchParent', 'match_parent'
  10. 13 'match_parent'
  11. when 'wrapContent', 'wrap_content'
  12. 16 'wrap_content'
  13. when Integer, Float
  14. 7 "#{value.to_i}dp"
  15. when /^\d+$/
  16. 1 "#{value}dp"
  17. when /^\d+\.\d+$/
  18. 1 "#{value.to_f.to_i}dp"
  19. when /^\d+dp$/
  20. 3 value
  21. when /^\d+%$/
  22. 1 "0dp" # Will need layout_weight
  23. else
  24. 3 value.to_s.empty? ? 'wrap_content' : value.to_s
  25. end
  26. end
  27. 1 def convert_dimension(value)
  28. 24 case value
  29. when Integer, Float
  30. 19 "#{value.to_i}dp"
  31. when String
  32. 2 if value.match?(/^\d+$/)
  33. 1 "#{value}dp"
  34. else
  35. 1 value
  36. end
  37. when Array
  38. # Use first value for now
  39. 2 convert_dimension(value.first || 0)
  40. else
  41. 1 value.to_s
  42. end
  43. end
  44. end
  45. end
  46. end

lib/xml/helpers/mappers/input_mapper.rb

95.24% lines covered

42 relevant lines. 40 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class InputMapper
  5. 1 def map_input_attributes(key, value)
  6. 45 case key
  7. # Input attributes
  8. when 'inputType'
  9. 6 return { namespace: 'android', name: 'inputType', value: map_input_type(value) }
  10. when 'placeholder'
  11. 1 return { namespace: 'android', name: 'hint', value: value }
  12. when 'editable'
  13. 1 return { namespace: 'android', name: 'editable', value: value.to_s }
  14. when 'singleLine'
  15. 1 return { namespace: 'android', name: 'singleLine', value: value.to_s }
  16. when 'maxLength'
  17. 1 return { namespace: 'android', name: 'maxLength', value: value.to_s }
  18. # Switch/Checkbox
  19. when 'checked', 'isChecked'
  20. 3 return { namespace: 'android', name: 'checked', value: process_checked_value(value) }
  21. # SelectBox/Spinner
  22. when 'selectedItem'
  23. 1 return { namespace: 'app', name: 'selectedValue', value: value }
  24. when 'entries', 'items'
  25. 2 if value.is_a?(Array)
  26. 2 return { namespace: 'app', name: 'items', value: value.join('|') }
  27. else
  28. return { namespace: 'app', name: 'items', value: value }
  29. end
  30. when 'selectItemType'
  31. return { namespace: 'tools', name: 'selectItemType', value: value }
  32. when 'hintColor'
  33. # Process color value through ResourceResolver
  34. 1 color_value = KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  35. 1 return { namespace: 'app', name: 'hintColor', value: color_value }
  36. when 'prompt'
  37. 1 return { namespace: 'app', name: 'placeholder', value: value }
  38. # Date picker attributes
  39. when 'datePickerMode', 'datePickerStyle'
  40. 1 return { namespace: 'app', name: 'datePickerMode', value: value }
  41. when 'dateFormat'
  42. 1 return { namespace: 'app', name: 'dateFormat', value: value }
  43. when 'minDate', 'minimumDate'
  44. 1 return { namespace: 'app', name: 'minDate', value: value }
  45. when 'maxDate', 'maximumDate'
  46. 1 return { namespace: 'app', name: 'maxDate', value: value }
  47. # Progress/Slider
  48. when 'progress'
  49. 1 return { namespace: 'android', name: 'progress', value: value.to_s }
  50. when 'max', 'maxValue', 'maximumValue'
  51. 1 return { namespace: 'android', name: 'max', value: value.to_f.to_i.to_s }
  52. when 'min', 'minValue', 'minimumValue'
  53. 1 return { namespace: 'android', name: 'min', value: value.to_f.to_i.to_s }
  54. when 'value'
  55. # For Slider, value maps to progress
  56. 2 return { namespace: 'android', name: 'progress', value: process_binding_value(value) }
  57. when 'onValueChange'
  58. 1 return nil # Handled in code generation
  59. # Events (will be handled in binding)
  60. when 'onClick', 'onclick'
  61. 1 return { namespace: 'android', name: 'onClick', value: value }
  62. when 'onTextChanged'
  63. 1 return nil # Handled in code
  64. end
  65. nil
  66. end
  67. 1 private
  68. 1 def map_input_type(value)
  69. input_type_map = {
  70. 6 'text' => 'text',
  71. 'number' => 'number',
  72. 'phone' => 'phone',
  73. 'email' => 'textEmailAddress',
  74. 'password' => 'textPassword',
  75. 'multiline' => 'textMultiLine'
  76. }
  77. 6 input_type_map[value] || value
  78. end
  79. 1 def process_checked_value(value)
  80. 3 if value.is_a?(String) && value.start_with?('@{')
  81. 1 value
  82. else
  83. 2 value.to_s
  84. end
  85. end
  86. 1 def process_binding_value(value)
  87. 2 if value.is_a?(String) && value.start_with?('@{')
  88. 1 value
  89. else
  90. 1 value.to_s
  91. end
  92. end
  93. end
  94. end
  95. end

lib/xml/helpers/mappers/layout_mapper.rb

62.96% lines covered

108 relevant lines. 68 lines covered and 40 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class LayoutMapper
  5. 1 def initialize(dimension_mapper)
  6. 105 @dimension_mapper = dimension_mapper
  7. end
  8. 1 def map_layout_attributes(key, value, component_type, parent_type)
  9. 44 case key
  10. # Dimension attributes
  11. when 'width'
  12. 2 return { namespace: 'android', name: 'layout_width', value: @dimension_mapper.map_dimension(value) }
  13. when 'height'
  14. 2 return { namespace: 'android', name: 'layout_height', value: @dimension_mapper.map_dimension(value) }
  15. # Padding attributes
  16. when 'padding', 'paddings'
  17. 4 if value.is_a?(Array)
  18. 1 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value.first || 0) }
  19. else
  20. 3 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value) }
  21. end
  22. when 'topPadding', 'paddingTop'
  23. 1 return { namespace: 'android', name: 'paddingTop', value: @dimension_mapper.convert_dimension(value) }
  24. when 'bottomPadding', 'paddingBottom'
  25. 1 return { namespace: 'android', name: 'paddingBottom', value: @dimension_mapper.convert_dimension(value) }
  26. when 'leftPadding', 'paddingLeft', 'startPadding', 'paddingStart'
  27. 1 return { namespace: 'android', name: 'paddingStart', value: @dimension_mapper.convert_dimension(value) }
  28. when 'rightPadding', 'paddingRight', 'endPadding', 'paddingEnd'
  29. 1 return { namespace: 'android', name: 'paddingEnd', value: @dimension_mapper.convert_dimension(value) }
  30. # Margin attributes
  31. when 'margin'
  32. 2 if value.is_a?(Array)
  33. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value.first || 0) }
  34. else
  35. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value) }
  36. end
  37. when 'topMargin', 'marginTop'
  38. 1 return { namespace: 'android', name: 'layout_marginTop', value: @dimension_mapper.convert_dimension(value) }
  39. when 'bottomMargin', 'marginBottom'
  40. 1 return { namespace: 'android', name: 'layout_marginBottom', value: @dimension_mapper.convert_dimension(value) }
  41. when 'leftMargin', 'marginLeft', 'startMargin', 'marginStart'
  42. 1 return { namespace: 'android', name: 'layout_marginStart', value: @dimension_mapper.convert_dimension(value) }
  43. when 'rightMargin', 'marginRight', 'endMargin', 'marginEnd'
  44. 1 return { namespace: 'android', name: 'layout_marginEnd', value: @dimension_mapper.convert_dimension(value) }
  45. # Layout specific
  46. when 'orientation'
  47. 1 return { namespace: 'android', name: 'orientation', value: value }
  48. when 'weight'
  49. 1 return { namespace: 'android', name: 'layout_weight', value: value.to_s }
  50. when 'gravity'
  51. 2 return { namespace: 'android', name: 'gravity', value: map_gravity(value) }
  52. when 'layout_gravity'
  53. 1 return { namespace: 'android', name: 'layout_gravity', value: map_gravity(value) }
  54. end
  55. nil
  56. end
  57. 1 def map_alignment_attributes(key, value, parent_type)
  58. # Check if parent is ConstraintLayout
  59. 38 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  60. 38 case key
  61. when 'alignTop'
  62. 3 if parent_type == 'LinearLayout'
  63. 1 return { namespace: 'android', name: 'layout_gravity', value: 'top' } if value
  64. 2 elsif is_constraint_layout
  65. 1 return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent' } if value
  66. else
  67. 1 return { namespace: 'android', name: 'layout_alignParentTop', value: value.to_s }
  68. end
  69. when 'alignBottom'
  70. 3 if parent_type == 'LinearLayout'
  71. 1 return { namespace: 'android', name: 'layout_gravity', value: 'bottom' } if value
  72. 2 elsif is_constraint_layout
  73. 2 return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: 'parent' } if value
  74. else
  75. return { namespace: 'android', name: 'layout_alignParentBottom', value: value.to_s }
  76. end
  77. when 'alignLeft', 'alignStart'
  78. 2 if parent_type == 'LinearLayout'
  79. 1 return { namespace: 'android', name: 'layout_gravity', value: 'start' } if value
  80. 1 elsif is_constraint_layout
  81. 1 return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent' } if value
  82. else
  83. return { namespace: 'android', name: 'layout_alignParentStart', value: value.to_s }
  84. end
  85. when 'alignRight', 'alignEnd'
  86. 2 if parent_type == 'LinearLayout'
  87. return { namespace: 'android', name: 'layout_gravity', value: 'end' } if value
  88. 2 elsif is_constraint_layout
  89. 2 return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: 'parent' } if value
  90. else
  91. return { namespace: 'android', name: 'layout_alignParentEnd', value: value.to_s }
  92. end
  93. when 'centerHorizontal'
  94. 2 if parent_type == 'LinearLayout'
  95. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center_horizontal' } if value
  96. 1 elsif is_constraint_layout
  97. # For horizontal centering in ConstraintLayout, we need both start and end constraints
  98. # This will be handled specially
  99. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_horizontal' } if value
  100. else
  101. 1 return { namespace: 'android', name: 'layout_centerHorizontal', value: value.to_s }
  102. end
  103. when 'centerVertical'
  104. if parent_type == 'LinearLayout'
  105. return { namespace: 'android', name: 'layout_gravity', value: 'center_vertical' } if value
  106. elsif is_constraint_layout
  107. # For vertical centering in ConstraintLayout, we need both top and bottom constraints
  108. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent', extra: 'center_vertical' } if value
  109. else
  110. return { namespace: 'android', name: 'layout_centerVertical', value: value.to_s }
  111. end
  112. when 'centerInParent'
  113. 1 if parent_type == 'LinearLayout'
  114. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center' } if value
  115. elsif is_constraint_layout
  116. # For centering in ConstraintLayout, we need all four constraints
  117. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_in_parent' } if value
  118. else
  119. return { namespace: 'android', name: 'layout_centerInParent', value: value.to_s }
  120. end
  121. # Relative positioning - align to edges of another view
  122. when 'alignTopView'
  123. if is_constraint_layout
  124. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}" }
  125. else
  126. return { namespace: 'android', name: 'layout_alignTop', value: "@id/#{value}" }
  127. end
  128. when 'alignBottomView'
  129. if is_constraint_layout
  130. return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: "@id/#{value}" }
  131. else
  132. return { namespace: 'android', name: 'layout_alignBottom', value: "@id/#{value}" }
  133. end
  134. when 'alignLeftView'
  135. if is_constraint_layout
  136. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}" }
  137. else
  138. return { namespace: 'android', name: 'layout_alignStart', value: "@id/#{value}" }
  139. end
  140. when 'alignRightView'
  141. if is_constraint_layout
  142. return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: "@id/#{value}" }
  143. else
  144. return { namespace: 'android', name: 'layout_alignEnd', value: "@id/#{value}" }
  145. end
  146. # Center alignment with another view (ConstraintLayout only)
  147. when 'alignCenterVerticalView'
  148. if is_constraint_layout
  149. # To center vertically with another view, constrain both top and bottom to that view
  150. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}", extra: 'center_vertical_to_view' }
  151. else
  152. puts "Warning: alignCenterVerticalView requires ConstraintLayout"
  153. return nil
  154. end
  155. when 'alignCenterHorizontalView'
  156. if is_constraint_layout
  157. # To center horizontally with another view, constrain both start and end to that view
  158. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}", extra: 'center_horizontal_to_view' }
  159. else
  160. puts "Warning: alignCenterHorizontalView requires ConstraintLayout"
  161. return nil
  162. end
  163. # Position relative to another view (outside edges)
  164. when 'alignTopOfView', 'above'
  165. 2 if is_constraint_layout
  166. 1 return { namespace: 'app', name: 'layout_constraintBottom_toTopOf', value: "@id/#{value}" }
  167. else
  168. 1 return { namespace: 'android', name: 'layout_above', value: "@id/#{value}" }
  169. end
  170. when 'alignBottomOfView', 'below'
  171. 2 if is_constraint_layout
  172. 1 return { namespace: 'app', name: 'layout_constraintTop_toBottomOf', value: "@id/#{value}" }
  173. else
  174. 1 return { namespace: 'android', name: 'layout_below', value: "@id/#{value}" }
  175. end
  176. when 'alignLeftOfView', 'toLeftOf'
  177. 1 if is_constraint_layout
  178. return { namespace: 'app', name: 'layout_constraintEnd_toStartOf', value: "@id/#{value}" }
  179. else
  180. 1 return { namespace: 'android', name: 'layout_toStartOf', value: "@id/#{value}" }
  181. end
  182. when 'alignRightOfView', 'toRightOf'
  183. 1 if is_constraint_layout
  184. return { namespace: 'app', name: 'layout_constraintStart_toEndOf', value: "@id/#{value}" }
  185. else
  186. 1 return { namespace: 'android', name: 'layout_toEndOf', value: "@id/#{value}" }
  187. end
  188. end
  189. nil
  190. end
  191. 1 private
  192. 1 def map_gravity(value)
  193. 3 if value.is_a?(Array)
  194. 1 value.join('|')
  195. else
  196. 2 case value
  197. when 'center'
  198. 2 'center'
  199. when 'left', 'start'
  200. 'start'
  201. when 'right', 'end'
  202. 'end'
  203. when 'top'
  204. 'top'
  205. when 'bottom'
  206. 'bottom'
  207. else
  208. value
  209. end
  210. end
  211. end
  212. end
  213. end
  214. end

lib/xml/helpers/mappers/style_mapper.rb

65.55% lines covered

119 relevant lines. 78 lines covered and 41 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class StyleMapper
  6. 1 def initialize(text_mapper, drawable_generator = nil)
  7. 103 @text_mapper = text_mapper
  8. 103 @drawable_generator = drawable_generator
  9. end
  10. 1 def map_style_attributes(key, value, json_element = nil, component_type = nil)
  11. 54 case key
  12. # Background and appearance
  13. when 'background', 'backgroundColor'
  14. # Check if we need to generate a drawable
  15. 3 if @drawable_generator && json_element && needs_drawable?(json_element, component_type)
  16. drawable_name = @drawable_generator.get_background_drawable(json_element, component_type)
  17. if drawable_name
  18. return { namespace: 'android', name: 'background', value: "@drawable/#{drawable_name}" }
  19. end
  20. end
  21. 3 return { namespace: 'android', name: 'background', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  22. when 'cornerRadius'
  23. # Handled in drawable generation
  24. return nil if @drawable_generator
  25. return { namespace: 'tools', name: 'cornerRadius', value: convert_dimension(value) }
  26. when 'borderWidth', 'strokeWidth'
  27. # Handled in drawable generation
  28. return nil if @drawable_generator
  29. return { namespace: 'tools', name: 'strokeWidth', value: convert_dimension(value) }
  30. when 'borderColor', 'strokeColor'
  31. # Handled in drawable generation
  32. return nil if @drawable_generator
  33. return { namespace: 'tools', name: 'strokeColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  34. when 'borderStyle'
  35. # Handled in drawable generation if available
  36. return nil if @drawable_generator
  37. return { namespace: 'tools', name: 'borderStyle', value: value }
  38. when 'opacity', 'alpha'
  39. 2 return { namespace: 'android', name: 'alpha', value: value.to_f.to_s }
  40. when 'visibility'
  41. 3 return { namespace: 'android', name: 'visibility', value: map_visibility(value) }
  42. when 'enabled'
  43. 1 return { namespace: 'android', name: 'enabled', value: value.to_s }
  44. when 'clickable'
  45. 1 return { namespace: 'android', name: 'clickable', value: value.to_s }
  46. when 'focusable'
  47. 1 return { namespace: 'android', name: 'focusable', value: value.to_s }
  48. # Image attributes
  49. when 'src', 'source', 'image'
  50. 3 return map_image_source(value, component_type)
  51. when 'url'
  52. # For NetworkImageView and CircleImageView
  53. 1 return { namespace: 'app', name: 'url', value: value }
  54. when 'placeholderImage'
  55. # For NetworkImageView placeholder image
  56. 1 if value.start_with?('@drawable/')
  57. return { namespace: 'app', name: 'placeholderImage', value: value }
  58. else
  59. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  60. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  61. end
  62. when 'placeholder'
  63. # For NetworkImageView/CircleImageView, use placeholderImage
  64. 1 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  65. 1 if value.start_with?('@drawable/')
  66. return { namespace: 'app', name: 'placeholderImage', value: value }
  67. else
  68. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  69. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  70. end
  71. end
  72. # For other components, let input_mapper handle it as hint
  73. return nil
  74. when 'errorImage', 'failureImage'
  75. # For NetworkImageView error image
  76. 1 if value.start_with?('@drawable/')
  77. return { namespace: 'app', name: 'errorImage', value: value }
  78. else
  79. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  80. 1 return { namespace: 'app', name: 'errorImage', value: "@drawable/#{resource_name}" }
  81. end
  82. when 'defaultImage', 'fallbackImage'
  83. # For NetworkImageView default/fallback image
  84. if value.start_with?('@drawable/')
  85. return { namespace: 'app', name: 'defaultImage', value: value }
  86. else
  87. resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  88. return { namespace: 'app', name: 'defaultImage', value: "@drawable/#{resource_name}" }
  89. end
  90. when 'crossfadeEnabled', 'crossfade'
  91. 1 return { namespace: 'app', name: 'crossfadeEnabled', value: value.to_s }
  92. when 'cacheEnabled'
  93. 1 return { namespace: 'app', name: 'cacheEnabled', value: value.to_s }
  94. when 'scaleType'
  95. 2 return { namespace: 'android', name: 'scaleType', value: map_scale_type(value) }
  96. when 'tint'
  97. 1 return { namespace: 'android', name: 'tint', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  98. # Blur attributes
  99. when 'blurRadius'
  100. 1 return { namespace: 'app', name: 'blurRadius', value: value.to_f.to_s }
  101. when 'blurOverlayColor'
  102. 1 return { namespace: 'app', name: 'blurOverlayColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  103. when 'downsampleFactor'
  104. 1 return { namespace: 'app', name: 'downsampleFactor', value: value.to_f.to_s }
  105. when 'blurEnabled'
  106. 1 return { namespace: 'app', name: 'blurEnabled', value: value.to_s }
  107. # Gradient attributes
  108. when 'gradientStartColor', 'startColor'
  109. 1 return { namespace: 'app', name: 'gradientStartColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  110. when 'gradientEndColor', 'endColor'
  111. 1 return { namespace: 'app', name: 'gradientEndColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  112. when 'gradientCenterColor', 'centerColor'
  113. return { namespace: 'app', name: 'gradientCenterColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  114. when 'gradientColors', 'colors'
  115. # Handle array of colors - don't process through ResourceResolver
  116. # gradientColors expects raw color values separated by |
  117. 1 if value.is_a?(Array)
  118. 4 colors_string = value.map { |c| normalize_color_for_gradient(c) }.join('|')
  119. 1 return { namespace: 'app', name: 'gradientColors', value: colors_string }
  120. else
  121. return { namespace: 'app', name: 'gradientColors', value: value }
  122. end
  123. when 'gradientDirection', 'direction'
  124. 2 return { namespace: 'app', name: 'gradientOrientation', value: map_gradient_direction(value) }
  125. when 'gradientAngle', 'angle'
  126. 1 return { namespace: 'app', name: 'gradientAngle', value: value.to_s }
  127. when 'gradientType'
  128. 2 return { namespace: 'app', name: 'gradientType', value: map_gradient_type(value) }
  129. when 'gradientRadius'
  130. 1 return { namespace: 'app', name: 'gradientRadius', value: value.to_f.to_s }
  131. when 'gradientCenterX'
  132. return { namespace: 'app', name: 'gradientCenterX', value: value.to_f.to_s }
  133. when 'gradientCenterY'
  134. return { namespace: 'app', name: 'gradientCenterY', value: value.to_f.to_s }
  135. # SafeAreaView attributes
  136. when 'safeAreaInsetPositions', 'insetPositions'
  137. # Handle array of positions
  138. 1 if value.is_a?(Array)
  139. 1 positions_string = value.join('|')
  140. 1 return { namespace: 'app', name: 'safeAreaInsetPositions', value: positions_string }
  141. else
  142. return { namespace: 'app', name: 'safeAreaInsetPositions', value: value }
  143. end
  144. when 'contentInsetAdjustmentBehavior'
  145. return { namespace: 'app', name: 'contentInsetAdjustmentBehavior', value: value.to_s }
  146. when 'applyTopInset'
  147. 1 return { namespace: 'app', name: 'applyTopInset', value: value.to_s }
  148. when 'applyBottomInset'
  149. 1 return { namespace: 'app', name: 'applyBottomInset', value: value.to_s }
  150. when 'applyLeftInset'
  151. return { namespace: 'app', name: 'applyLeftInset', value: value.to_s }
  152. when 'applyRightInset'
  153. return { namespace: 'app', name: 'applyRightInset', value: value.to_s }
  154. when 'applyStartInset'
  155. return { namespace: 'app', name: 'applyStartInset', value: value.to_s }
  156. when 'applyEndInset'
  157. return { namespace: 'app', name: 'applyEndInset', value: value.to_s }
  158. # State-specific attributes (handled by drawable generation)
  159. when 'disabledBackground', 'tapBackground', 'pressedBackground',
  160. 'selectedBackground', 'focusedBackground', 'checkedBackground',
  161. 'rippleColor', 'rippleBorderless'
  162. # These are handled by drawable generation
  163. return nil if @drawable_generator
  164. return { namespace: 'tools', name: key, value: value.to_s }
  165. end
  166. nil
  167. end
  168. 1 private
  169. 1 def needs_drawable?(json_element, component_type)
  170. return false unless json_element
  171. # Check if any drawable-related attributes exist
  172. json_element['cornerRadius'] ||
  173. json_element['borderWidth'] ||
  174. json_element['borderColor'] ||
  175. json_element['gradient'] ||
  176. json_element['disabledBackground'] ||
  177. json_element['tapBackground'] ||
  178. json_element['pressedBackground'] ||
  179. json_element['selectedBackground'] ||
  180. json_element['focusedBackground'] ||
  181. json_element['checkedBackground'] ||
  182. json_element['onClick'] ||
  183. json_element['onclick'] ||
  184. json_element['rippleColor'] ||
  185. ['Button', 'ImageButton', 'Card', 'ListItem'].include?(component_type)
  186. end
  187. 1 def convert_dimension(value)
  188. case value
  189. when Integer, Float
  190. "#{value.to_i}dp"
  191. when String
  192. if value.match?(/^\d+$/)
  193. "#{value}dp"
  194. else
  195. value
  196. end
  197. else
  198. value.to_s
  199. end
  200. end
  201. 1 def map_visibility(value)
  202. 3 case value
  203. when true, 'visible'
  204. 1 'visible'
  205. when false, 'gone'
  206. 1 'gone'
  207. when 'invisible'
  208. 1 'invisible'
  209. else
  210. value
  211. end
  212. end
  213. 1 def map_image_source(value, component_type = nil)
  214. # For NetworkImageView and CircleImageView, map src to url attribute
  215. 3 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  216. 1 return { namespace: 'app', name: 'url', value: value }
  217. end
  218. 2 if value.start_with?('http')
  219. # Network image - use tools for documentation
  220. 1 { namespace: 'tools', name: 'src', value: value }
  221. else
  222. # Local resource
  223. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  224. 1 { namespace: 'android', name: 'src', value: "@drawable/#{resource_name}" }
  225. end
  226. end
  227. 1 def map_scale_type(value)
  228. scale_type_map = {
  229. 2 'fill' => 'centerCrop',
  230. 'fit' => 'fitCenter',
  231. 'stretch' => 'fitXY',
  232. 'center' => 'center'
  233. }
  234. 2 scale_type_map[value] || value
  235. end
  236. 1 def map_gradient_direction(value)
  237. direction_map = {
  238. 2 'vertical' => 'top_bottom',
  239. 'horizontal' => 'left_right',
  240. 'diagonal' => 'tl_br',
  241. 'diagonal_reverse' => 'tr_bl',
  242. 'topBottom' => 'top_bottom',
  243. 'bottomTop' => 'bottom_top',
  244. 'leftRight' => 'left_right',
  245. 'rightLeft' => 'right_left',
  246. 'rightToLeft' => 'right_left',
  247. 'leftToRight' => 'left_right',
  248. 'topToBottom' => 'top_bottom',
  249. 'bottomToTop' => 'bottom_top',
  250. 'tlBr' => 'tl_br',
  251. 'trBl' => 'tr_bl',
  252. 'blTr' => 'bl_tr',
  253. 'brTl' => 'br_tl'
  254. }
  255. 2 direction_map[value] || 'top_bottom' # Default to top_bottom for unknown values
  256. end
  257. 1 def map_gradient_type(value)
  258. type_map = {
  259. 2 'linear' => 'linear',
  260. 'radial' => 'radial',
  261. 'sweep' => 'sweep',
  262. 'angular' => 'sweep'
  263. }
  264. 2 type_map[value] || 'linear'
  265. end
  266. 1 def normalize_color_for_gradient(color)
  267. 3 return '#00000000' if color == 'clear' || color == 'transparent'
  268. # Ensure hex format for colors
  269. 3 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  270. 3 color.start_with?('#') ? color : "##{color}"
  271. else
  272. # Return as-is for named colors or other formats
  273. color
  274. end
  275. end
  276. end
  277. end
  278. end

lib/xml/helpers/mappers/text_mapper.rb

93.06% lines covered

72 relevant lines. 67 lines covered and 5 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class TextMapper
  6. 1 def initialize(string_resource_manager = nil)
  7. 141 @string_resource_manager = string_resource_manager
  8. end
  9. 1 def map_text_attributes(key, value, component_type)
  10. 54 case key
  11. when 'text'
  12. 5 return { namespace: 'android', name: 'text', value: process_text_value(value) }
  13. when 'hint'
  14. 2 hint_value = process_hint_value(value)
  15. 2 return { namespace: 'android', name: 'hint', value: hint_value }
  16. when 'fontSize', 'textSize'
  17. 4 return { namespace: 'android', name: 'textSize', value: convert_text_size(value) }
  18. when 'fontColor', 'textColor'
  19. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  20. when 'color'
  21. # Generic color attribute - determine based on component type
  22. 3 if ['Label', 'Text', 'TextView', 'Button'].include?(component_type)
  23. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  24. else
  25. 1 return { namespace: 'android', name: 'tint', value: convert_color(value) }
  26. end
  27. when 'font'
  28. # Check if it's a font weight/style or a font file name
  29. 7 if ['bold', 'italic', 'normal', 'bold_italic'].include?(value.to_s.downcase)
  30. # It's a text style
  31. 2 return map_font_weight(value)
  32. 5 elsif ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  33. # It's a font file name for Kjui views
  34. # Add .ttf extension if not present
  35. 4 font_file = value.to_s
  36. 4 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  37. 4 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  38. else
  39. # For non-Kjui views, use as fontFamily
  40. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  41. end
  42. when 'fontFamily'
  43. # fontFamily is always treated as a font file name
  44. 2 if ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  45. 1 font_file = value.to_s
  46. 1 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  47. 1 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  48. else
  49. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  50. end
  51. when 'fontWeight'
  52. 6 return map_font_weight(value)
  53. when 'fontStyle'
  54. return { namespace: 'android', name: 'textStyle', value: value }
  55. when 'textAlign', 'textAlignment'
  56. 5 return { namespace: 'android', name: 'textAlignment', value: map_text_alignment(value) }
  57. when 'maxLines'
  58. 1 return { namespace: 'android', name: 'maxLines', value: value.to_s }
  59. when 'ellipsize'
  60. 1 return { namespace: 'android', name: 'ellipsize', value: value }
  61. end
  62. nil
  63. end
  64. 1 private
  65. 1 def process_hint_value(value)
  66. # Handle data binding
  67. 2 if value.is_a?(String) && value.start_with?('@{')
  68. 1 return value
  69. end
  70. # Convert value to string
  71. 1 text = value.to_s
  72. # Use ResourceResolver to check for string resources
  73. 1 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  74. end
  75. 1 def process_text_value(value)
  76. # Handle data binding
  77. 5 if value.is_a?(String) && value.start_with?('@{')
  78. 1 return value
  79. end
  80. # Convert value to string
  81. 4 text = value.to_s
  82. # Use ResourceResolver to check for string resources
  83. 4 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  84. end
  85. 1 def convert_text_size(value)
  86. 4 case value
  87. when Integer, Float
  88. 2 "#{value}sp"
  89. when String
  90. 2 if value.match?(/^\d+$/)
  91. 1 "#{value}sp"
  92. else
  93. 1 value
  94. end
  95. else
  96. "14sp"
  97. end
  98. end
  99. 1 def convert_color(value)
  100. 5 return nil if value.nil?
  101. # Handle special color values
  102. 5 if value.is_a?(String)
  103. 5 if value == 'clear' || value == 'transparent'
  104. return '#00000000'
  105. end
  106. # Use ResourceResolver to check for color resources
  107. 5 return KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  108. else
  109. value.to_s
  110. end
  111. end
  112. 1 def map_font_weight(value)
  113. 8 case value.to_s.downcase
  114. when 'bold'
  115. 2 { namespace: 'android', name: 'textStyle', value: 'bold' }
  116. when 'italic'
  117. 2 { namespace: 'android', name: 'textStyle', value: 'italic' }
  118. when 'bold_italic', 'bolditalic'
  119. 1 { namespace: 'android', name: 'textStyle', value: 'bold|italic' }
  120. when 'normal', 'regular', 'light', 'thin'
  121. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  122. when 'medium', 'semibold', 'heavy', 'black'
  123. # Medium and similar weights map to bold in Android
  124. 1 { namespace: 'android', name: 'textStyle', value: 'bold' }
  125. else
  126. # Default to normal for unknown values
  127. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  128. end
  129. end
  130. 1 def map_text_alignment(value)
  131. 5 case value
  132. when 'left', 'start'
  133. 2 'textStart'
  134. when 'right', 'end'
  135. 2 'textEnd'
  136. when 'center'
  137. 1 'center'
  138. else
  139. value
  140. end
  141. end
  142. end
  143. end
  144. end

lib/xml/helpers/resource_resolver.rb

81.44% lines covered

97 relevant lines. 79 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../../core/logger'
  4. 1 module KjuiTools
  5. 1 module Xml
  6. 1 module Helpers
  7. 1 class ResourceResolver
  8. 1 class << self
  9. # Load resources lazily
  10. 1 def strings_data
  11. 6 @strings_data ||= load_strings_data
  12. end
  13. 1 def colors_data
  14. 112 @colors_data ||= load_colors_data
  15. end
  16. 1 def defined_colors_data
  17. 56 @defined_colors_data ||= load_defined_colors_data
  18. end
  19. # Clear cache (useful when resources change)
  20. 1 def clear_cache
  21. 19 @strings_data = nil
  22. 19 @colors_data = nil
  23. 19 @defined_colors_data = nil
  24. end
  25. # Process text value - returns @string/key or original text
  26. 1 def process_text(text)
  27. 10 return text if text.nil? || text.empty?
  28. # Skip data binding expressions
  29. 8 return text if text.start_with?('@{') || text.start_with?('${')
  30. # Find string key
  31. 6 string_key = find_string_key(text)
  32. 6 if string_key
  33. 4 "@string/#{string_key}"
  34. else
  35. # Return original text wrapped in quotes for XML
  36. 2 "\"#{text}\""
  37. end
  38. end
  39. # Process color value - returns @color/key or hex color
  40. 1 def process_color(color)
  41. 61 return color if color.nil? || color.empty?
  42. # Skip data binding expressions
  43. 59 return color if color.start_with?('@{') || color.start_with?('${')
  44. # Skip if already a resource reference
  45. 57 return color if color.start_with?('@')
  46. # Find color key
  47. 56 color_key = find_color_key(color)
  48. 56 if color_key
  49. "@color/#{color_key}"
  50. else
  51. # Return hex color with # prefix
  52. 56 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  53. 56 color.start_with?('#') ? color : "##{color}"
  54. else
  55. color
  56. end
  57. end
  58. end
  59. 1 private
  60. 1 def load_strings_data
  61. 2 strings_file = find_strings_json
  62. 2 return {} unless strings_file && File.exist?(strings_file)
  63. begin
  64. 1 data = JSON.parse(File.read(strings_file))
  65. # Flatten the nested structure (file -> key -> value)
  66. 1 flattened = {}
  67. 1 data.each do |file_prefix, file_strings|
  68. 1 next unless file_strings.is_a?(Hash)
  69. 1 file_strings.each do |key, value|
  70. 1 full_key = "#{file_prefix}_#{key}"
  71. 1 flattened[full_key] = value
  72. end
  73. end
  74. 1 flattened
  75. rescue JSON::ParserError => e
  76. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  77. {}
  78. end
  79. end
  80. 1 def load_colors_data
  81. 4 colors_file = find_colors_json
  82. 4 return {} unless colors_file && File.exist?(colors_file)
  83. begin
  84. JSON.parse(File.read(colors_file))
  85. rescue JSON::ParserError => e
  86. Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  87. {}
  88. end
  89. end
  90. 1 def load_defined_colors_data
  91. 4 defined_colors_file = find_defined_colors_json
  92. 4 return {} unless defined_colors_file && File.exist?(defined_colors_file)
  93. begin
  94. JSON.parse(File.read(defined_colors_file))
  95. rescue JSON::ParserError => e
  96. Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  97. {}
  98. end
  99. end
  100. 1 def find_strings_json
  101. # Try common locations
  102. 2 paths = [
  103. 'src/main/assets/Layouts/Resources/strings.json',
  104. 'app/src/main/assets/Layouts/Resources/strings.json',
  105. 'sample-app/src/main/assets/Layouts/Resources/strings.json'
  106. ]
  107. 2 paths.each do |path|
  108. 4 full_path = File.expand_path(path)
  109. 4 return full_path if File.exist?(full_path)
  110. end
  111. nil
  112. end
  113. 1 def find_colors_json
  114. # Try common locations
  115. 4 paths = [
  116. 'src/main/assets/Layouts/Resources/colors.json',
  117. 'app/src/main/assets/Layouts/Resources/colors.json',
  118. 'sample-app/src/main/assets/Layouts/Resources/colors.json'
  119. ]
  120. 4 paths.each do |path|
  121. 12 full_path = File.expand_path(path)
  122. 12 return full_path if File.exist?(full_path)
  123. end
  124. nil
  125. end
  126. 1 def find_defined_colors_json
  127. # Try common locations
  128. 4 paths = [
  129. 'src/main/assets/Layouts/Resources/defined_colors.json',
  130. 'app/src/main/assets/Layouts/Resources/defined_colors.json',
  131. 'sample-app/src/main/assets/Layouts/Resources/defined_colors.json'
  132. ]
  133. 4 paths.each do |path|
  134. 12 full_path = File.expand_path(path)
  135. 12 return full_path if File.exist?(full_path)
  136. end
  137. nil
  138. end
  139. 1 def find_string_key(text)
  140. 11 strings_data.find { |key, value| value == text }&.first
  141. end
  142. 1 def find_color_key(color)
  143. # If the color itself is a key in colors.json, return it
  144. 56 if colors_data.key?(color)
  145. return color
  146. end
  147. # If the color itself is in defined_colors.json, return it
  148. 56 if defined_colors_data.key?(color)
  149. return color
  150. end
  151. # Otherwise, normalize and search for hex values
  152. 56 normalized_color = normalize_color(color)
  153. # Check colors.json for hex values
  154. 56 if colors_data.any? && normalized_color
  155. found = colors_data.find { |key, value| normalize_color(value) == normalized_color }
  156. return found.first if found
  157. end
  158. nil
  159. end
  160. 1 def normalize_color(color)
  161. 61 return nil if color.nil?
  162. # If it's a hex color, normalize it
  163. 60 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  164. 58 hex = color.gsub('#', '').upcase
  165. # Convert 3-digit to 6-digit
  166. 58 if hex.length == 3
  167. hex = hex.chars.map { |c| c * 2 }.join
  168. end
  169. 58 "##{hex}"
  170. else
  171. 2 color
  172. end
  173. end
  174. end
  175. end
  176. end
  177. end
  178. end

lib/xml/resources/string_resource_manager.rb

37.8% lines covered

82 relevant lines. 31 lines covered and 51 lines missed.
    
  1. 1 require 'nokogiri'
  2. 1 require 'fileutils'
  3. 1 module XmlGenerator
  4. 1 module Resources
  5. 1 class StringResourceManager
  6. 1 def initialize(project_root)
  7. 24 @project_root = project_root
  8. 24 @strings_file_path = find_strings_file
  9. 24 @strings_cache = {}
  10. 24 @new_strings = {}
  11. 24 load_existing_strings
  12. end
  13. # Get or create a string resource reference
  14. 1 def get_string_resource(text)
  15. return nil if text.nil? || text.empty?
  16. # Check if it's already a resource reference
  17. return text if text.start_with?('@string/')
  18. # Check if it's a data binding expression
  19. return text if text.start_with?('@{')
  20. # Check if text is too short or just numbers
  21. return text if text.length < 2 || text.match?(/^\d+$/)
  22. # Check existing strings
  23. existing_name = find_existing_string(text)
  24. return "@string/#{existing_name}" if existing_name
  25. # Check if we already created this string in this session
  26. new_name = @new_strings.key(text)
  27. return "@string/#{new_name}" if new_name
  28. # Create new string resource
  29. string_name = generate_string_name(text)
  30. @new_strings[string_name] = text
  31. "@string/#{string_name}"
  32. end
  33. # Save all new strings to strings.xml
  34. 1 def save_new_strings
  35. 3 return if @new_strings.empty?
  36. ensure_strings_file_exists
  37. # Load the XML file
  38. doc = Nokogiri::XML(File.read(@strings_file_path)) do |config|
  39. config.default_xml.noblanks
  40. end
  41. resources = doc.at_xpath('//resources')
  42. # Add new strings
  43. @new_strings.each do |name, value|
  44. # Skip if already exists (double check)
  45. next if doc.at_xpath("//string[@name='#{name}']")
  46. # Create new string element
  47. string_element = Nokogiri::XML::Node.new('string', doc)
  48. string_element['name'] = name
  49. # Process the value to handle line breaks properly
  50. processed_value = escape_xml_text(value)
  51. # Replace line breaks with \n for XML
  52. processed_value = processed_value.gsub(/\r?\n/, '\n')
  53. string_element.content = processed_value
  54. # Add to resources
  55. resources.add_child("\n ")
  56. resources.add_child(string_element)
  57. end
  58. # Add final newline if there are children
  59. if resources.children.any?
  60. resources.add_child("\n")
  61. end
  62. # Save the file
  63. File.write(@strings_file_path, doc.to_xml(
  64. indent: 4,
  65. indent_text: ' ',
  66. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  67. Nokogiri::XML::Node::SaveOptions::AS_XML
  68. ))
  69. puts "✅ Added #{@new_strings.size} new strings to strings.xml"
  70. # Add to cache for future lookups
  71. @strings_cache.merge!(@new_strings)
  72. @new_strings.clear
  73. end
  74. 1 private
  75. 1 def find_strings_file
  76. possible_paths = [
  77. 24 File.join(@project_root, 'src', 'main', 'res', 'values', 'strings.xml'),
  78. File.join(@project_root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
  79. File.join(@project_root, 'sample-app', 'src', 'main', 'res', 'values', 'strings.xml')
  80. ]
  81. 94 possible_paths.find { |path| File.exist?(path) } || possible_paths.first
  82. end
  83. 1 def ensure_strings_file_exists
  84. return if File.exist?(@strings_file_path)
  85. # Create directory if needed
  86. FileUtils.mkdir_p(File.dirname(@strings_file_path))
  87. # Create basic strings.xml
  88. content = <<~XML
  89. <?xml version="1.0" encoding="utf-8"?>
  90. <resources>
  91. <string name="app_name">App</string>
  92. </resources>
  93. XML
  94. File.write(@strings_file_path, content)
  95. end
  96. 1 def load_existing_strings
  97. 24 return unless File.exist?(@strings_file_path)
  98. begin
  99. 1 doc = Nokogiri::XML(File.read(@strings_file_path))
  100. # Load all existing strings into cache
  101. 1 doc.xpath('//string').each do |string_node|
  102. 1 name = string_node['name']
  103. 1 value = unescape_xml_text(string_node.text)
  104. 1 @strings_cache[name] = value if name && value
  105. end
  106. rescue => e
  107. puts "Warning: Could not parse strings.xml: #{e.message}"
  108. end
  109. end
  110. 1 def find_existing_string(text)
  111. # Exact match
  112. @strings_cache.find { |name, value| value == text }&.first
  113. end
  114. 1 def generate_string_name(text)
  115. # Generate a meaningful name from the text
  116. base_name = text.downcase
  117. .gsub(/[^a-z0-9\s_-]/, '') # Remove special characters
  118. .gsub(/-/, '_') # Replace hyphens with underscores
  119. .gsub(/\s+/, '_') # Replace spaces with underscores
  120. .gsub(/_+/, '_') # Remove duplicate underscores
  121. .gsub(/^_|_$/, '') # Remove leading/trailing underscores
  122. # Handle reserved words
  123. reserved_words = ['default', 'public', 'private', 'protected', 'static',
  124. 'final', 'abstract', 'class', 'interface', 'enum',
  125. 'package', 'import', 'return', 'if', 'else', 'switch',
  126. 'case', 'break', 'continue', 'for', 'while', 'do',
  127. 'try', 'catch', 'finally', 'throw', 'throws', 'new',
  128. 'this', 'super', 'extends', 'implements', 'void',
  129. 'boolean', 'int', 'long', 'float', 'double', 'char',
  130. 'byte', 'short', 'true', 'false', 'null']
  131. if reserved_words.include?(base_name)
  132. base_name = "str_#{base_name}"
  133. end
  134. # Limit length
  135. base_name = base_name[0..30] if base_name.length > 30
  136. # Ensure it starts with a letter
  137. base_name = "str_#{base_name}" unless base_name.match?(/^[a-z]/)
  138. # Handle empty or invalid names
  139. base_name = "str_text" if base_name.empty?
  140. # Make unique if needed
  141. final_name = base_name
  142. counter = 2
  143. while @strings_cache.key?(final_name) || @new_strings.key?(final_name)
  144. final_name = "#{base_name}_#{counter}"
  145. counter += 1
  146. end
  147. final_name
  148. end
  149. 1 def escape_xml_text(text)
  150. # Escape special characters for XML
  151. text.gsub('&', '&amp;')
  152. .gsub('<', '&lt;')
  153. .gsub('>', '&gt;')
  154. .gsub('"', '&quot;')
  155. .gsub("'", '&apos;')
  156. end
  157. 1 def unescape_xml_text(text)
  158. # Unescape XML entities
  159. 1 text.gsub('&amp;', '&')
  160. .gsub('&lt;', '<')
  161. .gsub('&gt;', '>')
  162. .gsub('&quot;', '"')
  163. .gsub('&apos;', "'")
  164. end
  165. end
  166. end
  167. end

lib/xml/xml_builder.rb

77.61% lines covered

134 relevant lines. 104 lines covered and 30 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../core/config_manager'
  5. 1 require_relative '../core/project_finder'
  6. 1 require_relative '../core/attribute_validator'
  7. 1 require_relative '../core/binding_validator'
  8. 1 require_relative 'xml_generator'
  9. 1 module KjuiTools
  10. 1 module Xml
  11. 1 class XmlBuilder
  12. 1 attr_accessor :validation_enabled, :validation_callback
  13. 1 def initialize(config = nil)
  14. 11 @config = config || Core::ConfigManager.load_config
  15. 11 Core::ProjectFinder.setup_paths
  16. # Use current directory as project path (where kjui.config.json is located)
  17. 11 @project_path = Dir.pwd
  18. 11 @layouts_dir = File.join(@project_path, @config['source_directory'] || 'src/main', @config['layouts_directory'] || 'assets/Layouts')
  19. 11 @output_dir = File.join(@project_path, @config['source_directory'] || 'src/main', 'res/layout')
  20. 11 @generated_count = 0
  21. 11 @failed_count = 0
  22. 11 @skipped_count = 0
  23. 11 @validation_enabled = false
  24. 11 @validation_callback = nil
  25. 11 @validator = nil
  26. 11 @binding_validator = nil
  27. end
  28. 1 def build(options = {})
  29. 7 puts "🔨 Building XML View files..."
  30. 7 puts "📁 Project: #{@project_path}"
  31. 7 puts "📂 Layouts: #{@layouts_dir}"
  32. 7 puts "📂 Output: #{@output_dir}"
  33. 7 puts "-" * 60
  34. 7 unless Dir.exist?(@layouts_dir)
  35. 1 puts "❌ Layouts directory not found: #{@layouts_dir}"
  36. 1 return false
  37. end
  38. # Clean output directory if requested
  39. 6 if options[:clean]
  40. 1 clean_output_directory
  41. end
  42. # Ensure output directory exists
  43. 6 FileUtils.mkdir_p(@output_dir)
  44. # Initialize validators if validation is enabled
  45. 6 @validator = Core::AttributeValidator.new(:xml) if @validation_enabled
  46. 6 @binding_validator = Core::BindingValidator.new if @validation_enabled
  47. # Get all JSON files (excluding Resources folder)
  48. 6 json_files = Dir.glob(File.join(@layouts_dir, '*.json'))
  49. # Also get JSON files from subdirectories, but exclude Resources
  50. 6 json_files += Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  51. 4 file.include?('/Resources/')
  52. end
  53. 6 json_files.uniq!
  54. 6 if json_files.empty?
  55. 2 puts "⚠️ No JSON files found in #{@layouts_dir}"
  56. 2 return true
  57. end
  58. 4 puts "📄 Found #{json_files.length} JSON files"
  59. 4 puts "-" * 60
  60. # Extract resources before processing layouts
  61. 4 require_relative '../core/resources_manager'
  62. 4 resources_manager = Core::ResourcesManager.new(@config, @project_path)
  63. 4 resources_manager.extract_resources(json_files)
  64. 4 puts "-" * 60
  65. # Process each file
  66. 4 json_files.each do |json_file|
  67. 4 process_layout(json_file, options)
  68. end
  69. # Print summary
  70. 4 puts "-" * 60
  71. 4 puts "✅ Build Complete!"
  72. 4 puts " Generated: #{@generated_count} files"
  73. 4 puts " Failed: #{@failed_count} files" if @failed_count > 0
  74. 4 puts " Skipped: #{@skipped_count} files" if @skipped_count > 0
  75. 4 @failed_count == 0
  76. end
  77. 1 private
  78. 1 def clean_output_directory
  79. 1 puts "🧹 Cleaning output directory..."
  80. 1 if Dir.exist?(@output_dir)
  81. # Only remove generated XML files (those with our comment marker)
  82. 1 Dir.glob(File.join(@output_dir, '*.xml')).each do |file|
  83. 1 content = File.read(file)
  84. 1 if content.include?('<!-- Generated from') && content.include?('.json')
  85. 1 puts " Removing: #{File.basename(file)}"
  86. 1 File.delete(file)
  87. end
  88. end
  89. end
  90. end
  91. # Validate a JSON component and all its children recursively
  92. 1 def validate_json(json_data)
  93. 1 return [] unless json_data.is_a?(Hash)
  94. 1 warnings = @validator.validate(json_data)
  95. # Validate children recursively
  96. 1 children = json_data['child'] || json_data['children'] || []
  97. 1 children = [children] unless children.is_a?(Array)
  98. 1 children.each do |child|
  99. warnings.concat(validate_json(child)) if child.is_a?(Hash)
  100. end
  101. # Validate sections (for Collection/Table)
  102. 1 if json_data['sections'].is_a?(Array)
  103. json_data['sections'].each do |section|
  104. if section.is_a?(Hash)
  105. ['header', 'footer', 'cell'].each do |key|
  106. warnings.concat(validate_json(section[key])) if section[key].is_a?(Hash)
  107. end
  108. end
  109. end
  110. end
  111. 1 warnings
  112. end
  113. 1 def process_layout(json_file, options = {})
  114. 4 layout_name = File.basename(json_file, '.json')
  115. # Skip partial/included files (convention: starts with underscore)
  116. 4 if layout_name.start_with?('_')
  117. 1 puts " ⏭️ Skipping partial: #{layout_name}"
  118. 1 @skipped_count += 1
  119. 1 return
  120. end
  121. # Skip cell templates (they're used in collections)
  122. 3 if layout_name.end_with?('_cell') || layout_name.include?('cell')
  123. 1 puts " ⏭️ Skipping cell template: #{layout_name}"
  124. 1 @skipped_count += 1
  125. 1 return
  126. end
  127. # Skip included files (used by include mechanism)
  128. 2 if layout_name.start_with?('included')
  129. puts " ⏭️ Skipping include file: #{layout_name}"
  130. @skipped_count += 1
  131. return
  132. end
  133. 2 print " 📝 Processing: #{layout_name}..."
  134. begin
  135. # Validate JSON if enabled
  136. 2 if @validation_enabled && @validator
  137. 1 json_content = File.read(json_file)
  138. 1 json_data = JSON.parse(json_content)
  139. 1 warnings = validate_json(json_data)
  140. 1 if warnings.any?
  141. 1 puts " ⚠️ #{warnings.length} attribute warning(s)"
  142. 1 @validation_callback&.call(layout_name, warnings)
  143. end
  144. # Validate bindings for business logic
  145. 1 if @binding_validator
  146. 1 binding_warnings = @binding_validator.validate(json_data, layout_name)
  147. 1 if binding_warnings.any?
  148. puts " ⚠️ #{binding_warnings.length} binding warning(s)"
  149. @validation_callback&.call(layout_name, binding_warnings)
  150. end
  151. end
  152. end
  153. # Ensure project_path is set in config
  154. 2 config_with_path = @config.merge('project_path' => @project_path)
  155. # Generate XML using the existing generator
  156. 2 generator = XmlGenerator::Generator.new(layout_name, config_with_path)
  157. 2 if generator.generate
  158. 2 @generated_count += 1
  159. 2 puts " ✅" unless @validation_enabled && warnings&.any?
  160. else
  161. @failed_count += 1
  162. puts " ❌"
  163. end
  164. rescue JSON::ParserError => e
  165. @failed_count += 1
  166. puts " ❌"
  167. puts " JSON Parse Error: #{e.message}"
  168. rescue => e
  169. @failed_count += 1
  170. puts " ❌"
  171. puts " Error: #{e.message}"
  172. puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n")
  173. end
  174. end
  175. end
  176. end
  177. end
  178. # Allow running directly
  179. 1 if __FILE__ == $0
  180. require_relative '../core/config_manager'
  181. config = KjuiTools::Core::ConfigManager.load_config
  182. builder = KjuiTools::Xml::XmlBuilder.new(config)
  183. options = {}
  184. ARGV.each do |arg|
  185. case arg
  186. when '--clean', '-c'
  187. options[:clean] = true
  188. when '--debug', '-d'
  189. config['debug'] = true
  190. when '--validate', '-v'
  191. builder.validation_enabled = true
  192. end
  193. end
  194. builder.build(options)
  195. end

lib/xml/xml_generator.rb

74.16% lines covered

209 relevant lines. 155 lines covered and 54 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require 'nokogiri'
  6. 1 require_relative '../core/json_loader'
  7. 1 require_relative '../core/style_loader'
  8. 1 require_relative 'helpers/component_mapper'
  9. 1 require_relative 'helpers/attribute_mapper'
  10. 1 require_relative 'helpers/binding_parser'
  11. 1 require_relative 'helpers/layout_attribute_processor'
  12. 1 require_relative 'helpers/data_binding_helper'
  13. 1 require_relative 'drawable/drawable_generator'
  14. 1 require_relative 'resources/string_resource_manager'
  15. 1 module XmlGenerator
  16. 1 class Generator
  17. 1 def initialize(layout_name, config, options = {})
  18. 24 @layout_name = layout_name
  19. 24 @config = config
  20. 24 @options = options
  21. 24 @json_loader = JsonLoader.new(config)
  22. 24 @style_loader = StyleLoader.new(config)
  23. 24 @component_mapper = ComponentMapper.new
  24. # Initialize resource managers
  25. 24 project_root = @config['project_path']
  26. 24 @drawable_generator = DrawableGenerator::Generator.new(project_root)
  27. 24 @string_resource_manager = Resources::StringResourceManager.new(project_root)
  28. 24 @attribute_mapper = AttributeMapper.new(@drawable_generator, @string_resource_manager)
  29. 24 @binding_parser = BindingParser.new
  30. 24 @layout_processor = LayoutAttributeProcessor.new(@attribute_mapper)
  31. # Get package name from config or auto-detect
  32. 24 @package_name = @config['package_name'] || detect_package_name
  33. # Allow custom output filename
  34. 24 @output_filename = options[:output_filename]
  35. end
  36. 1 def generate
  37. 4 puts "Generating XML for #{@layout_name}..."
  38. # Load JSON
  39. 4 json_content = @json_loader.load_layout(@layout_name)
  40. 4 if json_content.nil?
  41. 1 puts "Error: Could not load layout #{@layout_name}"
  42. 1 return false
  43. end
  44. # Parse JSON
  45. 3 layout_data = JSON.parse(json_content)
  46. # Apply styles
  47. 3 layout_data = @style_loader.apply_styles(layout_data)
  48. # Generate XML
  49. 3 xml_content = generate_xml(layout_data)
  50. # Save XML file
  51. 3 save_xml(xml_content)
  52. # Save any new strings to strings.xml
  53. 3 @string_resource_manager.save_new_strings
  54. 3 true
  55. rescue => e
  56. puts "Error generating XML: #{e.message}"
  57. puts " Backtrace:"
  58. e.backtrace[0..4].each { |line| puts " #{line}" }
  59. false
  60. end
  61. 1 private
  62. 1 def detect_package_name
  63. # Try to detect from AndroidManifest.xml
  64. manifest_paths = [
  65. File.join(@config['project_path'], 'src', 'main', 'AndroidManifest.xml'),
  66. File.join(@config['project_path'], 'app', 'src', 'main', 'AndroidManifest.xml')
  67. ]
  68. manifest_paths.each do |path|
  69. if File.exist?(path)
  70. content = File.read(path)
  71. if content =~ /package="([^"]+)"/
  72. return $1
  73. end
  74. end
  75. end
  76. # Try to detect from build.gradle
  77. gradle_paths = [
  78. File.join(@config['project_path'], 'build.gradle'),
  79. File.join(@config['project_path'], 'app', 'build.gradle'),
  80. File.join(@config['project_path'], 'build.gradle.kts'),
  81. File.join(@config['project_path'], 'app', 'build.gradle.kts')
  82. ]
  83. gradle_paths.each do |path|
  84. if File.exist?(path)
  85. content = File.read(path)
  86. # Look for namespace
  87. if content =~ /namespace\s*[=:]\s*["']([^"']+)["']/
  88. return $1
  89. end
  90. # Look for applicationId
  91. if content =~ /applicationId\s*[=:]\s*["']([^"']+)["']/
  92. return $1
  93. end
  94. end
  95. end
  96. # Default
  97. 'com.example.app'
  98. end
  99. 1 def generate_xml(json_data)
  100. # Check if layout uses data binding
  101. 3 has_binding = check_for_bindings(json_data)
  102. 3 if has_binding
  103. generate_data_binding_xml(json_data)
  104. else
  105. 3 generate_regular_xml(json_data)
  106. end
  107. end
  108. 1 def check_for_bindings(json_data)
  109. # Recursively check for @{} syntax in the JSON
  110. 5 json_string = json_data.to_json
  111. 5 json_string.include?('@{')
  112. end
  113. 1 def generate_data_binding_xml(json_data)
  114. # Extract all binding variables
  115. variables = extract_binding_variables(json_data)
  116. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  117. xml.comment " Generated from #{@layout_name}.json with Data Binding "
  118. xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  119. # Create layout root for data binding
  120. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  121. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  122. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  123. # Add data section
  124. xml.data do
  125. # Add common imports
  126. xml.import(type: 'android.view.View')
  127. # Add data variable
  128. if has_data_definitions?(json_data)
  129. data_class = "#{camelize(@layout_name)}Data"
  130. xml.variable(name: 'data', type: "#{@package_name}.data.#{data_class}")
  131. end
  132. # Add viewModel variable if there are onClick handlers
  133. if has_click_handlers?(json_data)
  134. view_model_class = "#{camelize(@layout_name)}ViewModel"
  135. xml.variable(name: 'viewModel', type: "#{@package_name}.viewmodels.#{view_model_class}")
  136. end
  137. end
  138. # Add the actual layout content
  139. # Pass false for is_root since namespaces are already on <layout> tag
  140. create_xml_element(xml, json_data, false)
  141. end
  142. end
  143. # Format the XML nicely
  144. doc = Nokogiri::XML(builder.to_xml) do |config|
  145. config.default_xml.noblanks
  146. end
  147. # Pretty print with proper indentation
  148. formatted_xml = doc.to_xml(
  149. indent: 4,
  150. indent_text: ' ',
  151. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  152. Nokogiri::XML::Node::SaveOptions::AS_XML
  153. )
  154. # Additional formatting: put each attribute on a new line for better readability
  155. format_attributes(formatted_xml)
  156. end
  157. 1 def generate_regular_xml(json_data)
  158. 3 builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  159. 3 xml.comment " Generated from #{@layout_name}.json "
  160. 3 xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  161. # Create root layout
  162. 3 create_xml_element(xml, json_data, true)
  163. end
  164. # Format the XML nicely
  165. 3 doc = Nokogiri::XML(builder.to_xml) do |config|
  166. 3 config.default_xml.noblanks
  167. end
  168. # Pretty print with proper indentation
  169. 3 formatted_xml = doc.to_xml(
  170. indent: 4,
  171. indent_text: ' ',
  172. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  173. Nokogiri::XML::Node::SaveOptions::AS_XML
  174. )
  175. # Additional formatting: put each attribute on a new line for better readability
  176. 3 format_attributes(formatted_xml)
  177. end
  178. 1 def extract_binding_variables(json_data)
  179. 2 variables = Set.new
  180. 2 extract_variables_recursive(json_data, variables)
  181. 2 variables
  182. end
  183. 1 def extract_variables_recursive(data, variables)
  184. 5 if data.is_a?(Hash)
  185. 4 data.each do |key, value|
  186. 5 if value.is_a?(String) && value.start_with?('@{')
  187. # Extract variable name from binding expression
  188. 3 if value.match(/@\{([^}]+)\}/)
  189. 3 expr = $1
  190. # Simple variable extraction (can be enhanced)
  191. 3 if expr.match(/^(\w+)/)
  192. 3 variables.add($1)
  193. end
  194. end
  195. 2 elsif value.is_a?(Hash) || value.is_a?(Array)
  196. 1 extract_variables_recursive(value, variables)
  197. end
  198. end
  199. 1 elsif data.is_a?(Array)
  200. 3 data.each { |item| extract_variables_recursive(item, variables) }
  201. end
  202. end
  203. 1 def has_click_handlers?(json_data)
  204. 3 json_string = json_data.to_json
  205. 3 json_string.include?('"onClick"') || json_string.include?('"onclick"')
  206. end
  207. 1 def camelize(snake_case)
  208. 2 snake_case.split('_').map(&:capitalize).join
  209. end
  210. 1 def needs_tools_namespace?(json_element)
  211. # Check if this element or any of its children use tools attributes
  212. 6 json_string = json_element.to_json
  213. 6 json_string.include?('"tools:') || json_string.include?('"title"') || json_string.include?('"count"')
  214. end
  215. 1 def has_data_definitions?(json_data)
  216. # Check if there are any data definitions anywhere in the JSON structure
  217. 3 return true if json_data['data']
  218. # Check children recursively
  219. 2 if json_data['child']
  220. 1 children = json_data['child'].is_a?(Array) ? json_data['child'] : [json_data['child']]
  221. 1 children.each do |child|
  222. 1 return true if child.is_a?(Hash) && child['data']
  223. return true if child.is_a?(Hash) && has_data_definitions?(child)
  224. end
  225. end
  226. 1 if json_data['children']
  227. children = json_data['children'].is_a?(Array) ? json_data['children'] : [json_data['children']]
  228. children.each do |child|
  229. return true if child.is_a?(Hash) && child['data']
  230. return true if child.is_a?(Hash) && has_data_definitions?(child)
  231. end
  232. end
  233. 1 false
  234. end
  235. 1 def create_xml_element(xml, json_element, is_root = false, parent_orientation = nil, parent_type = nil)
  236. # Map JSON type to Android view class (pass json_element for View type checking)
  237. 5 view_class = @component_mapper.map_component(json_element['type'], json_element)
  238. # Prepare all attributes first
  239. 5 attrs = {}
  240. # Add namespace declarations if this is the root element
  241. 5 if is_root
  242. 3 attrs['xmlns:android'] = 'http://schemas.android.com/apk/res/android'
  243. # Always add app namespace as it's commonly needed for ConstraintLayout and custom attributes
  244. 3 attrs['xmlns:app'] = 'http://schemas.android.com/apk/res-auto'
  245. # Add tools namespace if we're using tools attributes
  246. 3 if needs_tools_namespace?(json_element)
  247. attrs['xmlns:tools'] = 'http://schemas.android.com/tools'
  248. end
  249. end
  250. # Add ID if present
  251. 5 if json_element['id']
  252. attrs['android:id'] = "@+id/#{json_element['id']}"
  253. end
  254. # Process layout dimensions
  255. 5 dimension_attrs = @layout_processor.process_dimensions(json_element, is_root, parent_orientation)
  256. 5 attrs.merge!(dimension_attrs)
  257. # Process orientation for LinearLayout
  258. 5 orientation_attrs = @layout_processor.process_orientation(view_class, json_element)
  259. 5 attrs.merge!(orientation_attrs)
  260. # Process all other attributes
  261. 5 other_attrs = @layout_processor.process_attributes(json_element, parent_type)
  262. 5 attrs.merge!(other_attrs)
  263. # Determine orientation for children
  264. 5 current_orientation = nil
  265. 5 if view_class == 'LinearLayout'
  266. current_orientation = json_element['orientation'] || 'vertical'
  267. end
  268. # Create element with attributes
  269. # For custom views with package name, use the full class name
  270. 5 if view_class.include?('.')
  271. # Custom view with package name - create element directly
  272. 5 xml.send(:method_missing, view_class, attrs) do
  273. 5 create_children(xml, json_element, current_orientation, view_class)
  274. end
  275. else
  276. # Standard Android view
  277. xml.send(view_class, attrs) do
  278. create_children(xml, json_element, current_orientation, view_class)
  279. end
  280. end
  281. end
  282. 1 def create_children(parent_element, json_element, parent_orientation = nil, parent_type = nil)
  283. # Handle children
  284. 5 children = json_element['children'] || json_element['child']
  285. 5 return unless children
  286. 3 children = [children] unless children.is_a?(Array)
  287. 3 children.each do |child|
  288. # Skip data definitions - they don't create UI elements
  289. 2 next if child.is_a?(Hash) && child.key?('data') && !child.key?('type')
  290. 2 create_xml_element(parent_element, child, false, parent_orientation, parent_type)
  291. end
  292. end
  293. 1 def format_attributes(xml_string)
  294. # Format XML to put each attribute on its own line for better readability
  295. 6 lines = xml_string.split("\n")
  296. 6 formatted_lines = []
  297. 6 lines.each do |line|
  298. # Skip comments and empty lines
  299. 19 if line.strip.start_with?('<!--') || line.strip.start_with?('<?xml') || line.strip.empty?
  300. 11 formatted_lines << line
  301. 11 next
  302. end
  303. # Check if line contains an XML tag with attributes
  304. 8 if line =~ /^(\s*)<([^\/\s>]+)(.*?)(\s*\/?>.*?)$/
  305. 6 indent = $1
  306. 6 tag_name = $2
  307. 6 attributes_str = $3
  308. 6 tag_end = $4
  309. # Parse all attributes including namespace prefixes
  310. 6 attributes = []
  311. 6 attributes_str.scan(/(\S+?)="([^"]*)"/) do |attr_name, attr_value|
  312. 23 attributes << [attr_name, attr_value]
  313. end
  314. # Format based on number of attributes
  315. 6 if attributes.size > 1
  316. # Multiple attributes - put each on its own line
  317. 5 formatted_lines << "#{indent}<#{tag_name}"
  318. 5 attributes.each do |attr_name, attr_value|
  319. 22 formatted_lines << "#{indent} #{attr_name}=\"#{attr_value}\""
  320. end
  321. # Handle closing tag
  322. 5 if tag_end.strip == '/>'
  323. 3 formatted_lines[-1] += '/>'
  324. 2 elsif tag_end.include?('>')
  325. # Check if there's content after the >
  326. 2 if tag_end =~ />\s*(.+)$/
  327. content = $1
  328. formatted_lines[-1] += '>'
  329. # Add the content on the same line if it's simple text
  330. if content && !content.empty?
  331. formatted_lines[-1] += content
  332. end
  333. else
  334. 2 formatted_lines[-1] += '>'
  335. end
  336. else
  337. formatted_lines[-1] += tag_end.strip
  338. end
  339. 1 elsif attributes.size == 1
  340. # Single attribute - can stay on one line
  341. 1 formatted_lines << line
  342. else
  343. # No attributes
  344. formatted_lines << line
  345. end
  346. else
  347. # Not a tag line or closing tag
  348. 2 formatted_lines << line
  349. end
  350. end
  351. 6 formatted_lines.join("\n")
  352. end
  353. 1 def save_xml(xml_content)
  354. # Determine output path
  355. 3 output_dir = File.join(@config['project_path'], 'src', 'main', 'res', 'layout')
  356. 3 output_dir = File.join(@config['project_path'], 'app', 'src', 'main', 'res', 'layout') if File.exist?(File.join(@config['project_path'], 'app'))
  357. 3 FileUtils.mkdir_p(output_dir)
  358. # Use custom filename if provided, otherwise use default
  359. 3 filename = @output_filename || "#{@layout_name.downcase}.xml"
  360. 3 output_file = File.join(output_dir, filename)
  361. # Save XML file
  362. 3 File.write(output_file, xml_content)
  363. 3 puts "✅ Generated: #{output_file}"
  364. end
  365. end
  366. end